Capturing mouseover events on two overlapping elements - d3.js

So I have a d3 chart with a rect overlay to hold crosshair elements on mouseover events. Under the overlay I have other rects displaying data that have mouseover event handlers also, but The overlay is blocking mouseover events form triggeron the children rects below.
let chartWindow = svg
.append("g");
/* this holds axis groups, and cadlestick group*/
let candleStickWindow = chartWindow.append("g")
//this event never fires
.on('mousemove', ()=>console.log('mouse move'));
let candlesCrosshairWindow = chartWindow
.append("rect")
.attr("class", "overlay")
.attr("height", innerHeight)
.attr("width", innerWidth)
.on("mouseover", function() {
crosshair.style("display", null);
})
.on("mouseout", function() {
crosshair.style("display", "none");
removeAllAxisAnnotations();
})
.on("mousemove", mousemove);
The CrosshairWindow has CSS property pointer-events: all. If I remove that, I get my events to fire on the candleStickWindow but not the CrosshairWindow. How can I get mouse events onto both of the elements??
Thanks for any help!
Update
I changed the crosshair rect element to be on the bottom and it kinda works, the candlestick bars mouseover event works but it blocks the crosshair from working.

One solution that comes to mind might use event bubbling which, however, only works if the events can bubble up along the same DOM sub-tree. If, in your DOM structure, the crosshairs rectangle and the other elements do not share a common ancestor to which you could reasonably attach such listener, you need to either rethink your DOM or resort to some other solution. For this answer I will lay out an alternative approach which is more generally applicable.
You can position your full-size rect at the very bottom of your SVG and have its pointer-events set to all. That way you can easily attach a mousemove handler to it to control your crosshairs' movements spanning the entire viewport. As you have noticed yourself, however, this does not work if there are elements above which have listeners for that particular event type attached to them. Because in that case, once the event has reached its target, there is no way propagating it further to the underlying rectangle for handling the crosshairs component. The work-around is easy, though, since you can clone the event and dispatch that new one directly to your rectangle.
Cloning the event is done by using the MouseEvent() constructor passing in the event's details from the d3.event reference:
new MouseEvent(d3.event.type, d3.event)
You can then dispatch the newly created event object to your crosshairs rect element by using the .dispatchEvent() method of the EventTarget interface which is implemented by SVGRectElement:
.dispatchEvent(new MouseEvent(d3.event.type, d3.event));
For lack of a full example in your question I set up a working demo myself illustrating the approach. You can drag around the blue circle which is a boiled down version of your crosshairs component. Notice, how the circle can be seamlessly moved around even when under the orange rectangles. To demonstrate the event handlers attached to those small rectangles they will transition to green and back to orange when entering or leaving them with the mouse pointer.
const width = 500;
const height = 500;
const radius = 10;
const orange = d3.hsl("orange");
const steelblue = d3.hsl("steelblue");
const limegreen = d3.hsl("limegreen");
const svg = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", height);
const target = svg.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("mousemove", () => {
circle.attr("cx", d3.event.clientX - radius);
circle.attr("cy", d3.event.clientY - radius);
});
const circle = svg.append("circle")
.attr("r", radius)
.attr("fill", steelblue)
.attr("pointer-events", "none");
const rect = svg.selectAll(null)
.data(d3.range(3).map(d => [Math.random() * width, Math.random() * height]))
.enter().append("rect")
.attr("x", d => d[0])
.attr("y", d => d[1])
.attr("width", 50)
.attr("height", 50)
.attr("fill", orange)
.attr("opacity", 0.5)
.on("mouseover", function() {
d3.select(this).transition().attr("fill", limegreen);
})
.on("mousemove", function() {
target.node().dispatchEvent(new MouseEvent(d3.event.type, d3.event));
})
.on("mouseout", function() {
d3.select(this).transition().attr("fill", orange);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

Related

Append new shapes to existing shapes within SVG document D3

In D3 I'm used to creating visuals from a blank div. However I'm trying to get my head around the following.
I have an svg document (https://raw.githubusercontent.com/gangrel11/samplefiles/main/d3%20task1.svg) which is just 3 rectangles.
What I'm trying to do is append 3 new shapes (circles) to each one of the existing rectangles so that they appear in the centre of each one.
This is where I got to:
const svgUrl = "https://raw.githubusercontent.com/gangrel11/samplefiles/main/d3%20task1.svg"
d3.xml(svgUrl).then(render);
function render(svg) {
// add svg
d3.select("body").node().append(svg.documentElement)
var svg = d3.select("body").append("svg");
svg.selectAll("rect")
.append("rect")
.attr("width", 20)
.attr("height", 20)
.attr("x", 20)
.attr("y", 10)
.attr("fill", "red")
}
Here is my jsfiddle
You have a few problems:
You are adding another svg element instead of using the existing one.
My suggestion is changing:
d3.select("body").append("svg");
to
d3.select("body").select("svg #layer1");
notice that I also targeted the g element #layer1 that get transformed.
you try to append rect element to rect element but svg doesn't know how to draw rect inside rect - this syntax is invalid.
instead, you can target each element and use his position using the .each method and append them after all the existing rects.
code:
function render(svg) {
// add svg
d3.select("body").node().append(svg.documentElement)
var svg = d3.select("body").select("svg #layer1");
svg.selectAll("rect")
.each(function (rect){
const {x, y} = this.getBoundingClientRect();
svg.append("rect")
.attr("width", 20)
.attr("height", 20)
.attr("x", this.getAttribute('x'))
.attr("y", this.getAttribute('y'))
.attr("fill", "red");
});
}

reversing order of d3.zoom scale and translate

If you click the red button in this example:
https://bl.ocks.org/interwebjill/fe782e6f195b17f6fe6798a24c390d90
you can see that the chart translates so that the circle is in the center and then zooms in to a specified level (reclicking on the button zooms back out). Translating and then zooming in this way leaves a gap on the left that I would rather not have. How might I change the code so that the chart zooms first and then translates to center so that I don't have this gap in the chart?
I have tried reversing the order of the scale and translate in both the zoom definition and the zoomToExtent function but there is no different in effect.
The ultimate source of the problem is d3.interpolateZoom. This interpolator has scale interpolate faster than translate - even though they mostly both are transitioning at the same time. The pattern implemented with d3.interpolateZoom is based on this paper.
Because scale and translate both interpolate differently in d3.interpolateZoom, you get a gap in the side of your chart as the scale decreases/increases more rapidly than the translate values.
d3.interpolateZoom is used when you call the zoom on a transition.
However, if you apply a transform directly on a transition using .attr(), the d3 transition will use d3.interpolateString, which will search the start and end strings for corresponding numbers and use d3.interpolateNumber on those. This will apply the same interpolation to both scale and translate.
Using both methods we can compare the discrepancy between d3.interpolateZoom and d3.interpolateString. Below the black rectangle uses d3.interpolateString while the orange rectangle uses d3.interpolateZoom. Click on a rectangle to start the transition:
var svg = d3.select("body").append("svg")
.attr("width", 500)
.attr("height", 300);
var g1 = svg.append("g"), g2 = svg.append("g");
var zoom1 = d3.zoom().on("zoom", function() {
g1.attr("transform", d3.event.transform);
});
var zoom2 = d3.zoom().on("zoom", function() {
g2.attr("transform", d3.event.transform);
});
g1.call(zoom1.transform, d3.zoomIdentity
.translate(150, 100)
.scale(2));
g2.call(zoom2.transform, d3.zoomIdentity
.translate(150,100)
.scale(2));
g1.append("rect")
.attr("x", 20)
.attr("y", 20)
.attr("width", 50)
.attr("height", 50);
g2.append("rect")
.attr("x", 22)
.attr("y", 22)
.attr("width", 46)
.attr("height",46)
.attr("fill","orange");
d3.selectAll("rect").on("click", function() {
g1.transition()
.duration(6000)
.attr("transform", d3.zoomIdentity)
.on("end", function() {
d3.select(this).call(zoom1.transform, d3.zoomIdentity);
})
g2.transition()
.duration(6000)
.call(zoom2.transform, d3.zoomIdentity)
});
<script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>
Where the first rectangle transitions the transform with .attr(), we need to call the zoom afterwards to ensure the zoom has the current transform, we don't need to in this example, but if you wanted to use the zoom after the transform you need to do this
Comparing these two we get:
(Y axis indicates percentage remaining in transition from start attribute to end attribute)
You want scale and translate to move simultaneously at the same rate when transitioning. We can do this if we use a tweening function. Unlike above we can't just use transition().attr("transform",newTransfrom) because you are also drawing canvas and updating the axis. So we'll need to create our own tweening function that can use the current transform and scale, apply it to the axis, canvas, and markers.
For example, rather than calling the zoom (which will use d3.interpolateZoom):
function zoomToExtent(d0, d1) {
zoomRect.call(zoom).transition()
.duration(1500)
.call(zoom.transform, d3.zoomIdentity
.translate(-xSVG(d0), 0)
.scale(width / (xSVG(d1) - xSVG(d0))));
}
Instead, we can use a tweening function which controls the element's transform and applies the same interpolator to scale and translate:
function zoomToExtent(d0, d1) {
//get transition start and end values:
var startScale = d3.zoomTransform(zoomRect.node()).k;
var startTranslate = d3.zoomTransform(zoomRect.node()).x;
var endTranslate = -xSVG(d0);
var endScale = width / (xSVG(d1) - xSVG(d0));
zoomRect.call(zoom).transition()
.duration(1500)
.tween("transform", function() {
var interpolateScale = d3.interpolateNumber(startScale,endScale);
var interpolateTranslate = d3.interpolateNumber(startTranslate,endTranslate);
return function(t) {
var t = d3.zoomIdentity.translate(interpolateTranslate(t),0).scale(interpolateScale(t));
zoomed(t);
}
})
.on("end", function() { // update the zoom identity on end:
d3.select(this).call(zoom.transform, d3.zoomIdentity
.translate(endTranslate, 0)
.scale(endScale));
})
}
You may notice I'm passing a transform value to the zoomed function, since there is no d3.event.transform for this, we need to modify the zoomed function to use the passed parameter if available, otherwise to fall back on the event transform:
function zoomed(transform) {
var t = transform || d3.event.transform;
...
Altogether, that might look something like this.
For another comparison between the two transitioning methods, I've created a gridded comparison that can be toggled between the two zoom identities:
var svg = d3.select("body").append("svg")
.attr("width", 510)
.attr("height", 310);
var g1 = svg.append("g");
var g2 = svg.append("g");
var rectangles1 = g1.selectAll()
.data(d3.range(750))
.enter()
.append("rect")
.attr("x", function(d) { return d%25*20; })
.attr("y", function(d) { return Math.floor(d/25)*20; })
.attr("width", 20)
.attr("height", 20)
.attr("fill","#ccc")
.attr("stroke","white")
.attr("stroke-width", 2);
var rectangles2 = g2.selectAll()
.data(d3.range(750))
.enter()
.append("rect")
.attr("x", function(d) { return d%25*20; })
.attr("y", function(d) { return Math.floor(d/25)*20; })
.attr("width", 20)
.attr("height", 20)
.attr("fill","none")
.attr("stroke","#444")
.attr("stroke-width", 1);
var startZoom = d3.zoomIdentity
.translate(-250,-200)
.scale(4);
var endZoom = d3.zoomIdentity
.translate(-100,-100)
.scale(5);
var zoom1 = d3.zoom().on("zoom", function() { g1.attr("transform", d3.event.transform); });
var zoom2 = d3.zoom().on("zoom", function() { g2.attr("transform", d3.event.transform); });
g1.call(zoom1.transform, startZoom);
g2.call(zoom2.transform, startZoom);
var toggle = true;
svg.on("click", function() {
toggle = !toggle;
g1.transition()
.duration(5000)
.call(zoom1.transform, toggle ? startZoom: endZoom)
g2.transition()
.duration(5000)
.attr("transform", toggle ? startZoom: endZoom)
.on("end", function() {
d3.select(this).call(zoom2.transform, toggle ? startZoom: endZoom);
})
})
rect {
opacity: 0.5;
}
<script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>

D3.js get data index of shape on mouseover event

I am attempting to access the data index of a shape on mouseover so that I can control the behavior of the shape based on the index.
Lets say that this block of code lays out 5 rect in a vertical line based on some data.
var g_box = svg
.selectAll("g")
.data(controls)
.enter()
.append("g")
.attr("transform", function (d,i){
return "translate("+(width - 100)+","+((controlBoxSize+5)+i*(controlBoxSize+ 5))+")"
})
.attr("class", "controls");
g_box
.append("rect")
.attr("class", "control")
.attr("width", 15)
.attr("height", 15)
.style("stroke", "black")
.style("fill", "#b8b9bc");
When we mouseover rect 3, it transitions to double size.
g_box.selectAll("rect")
.on("mouseover", function(d){
d3.select(this)
.transition()
.attr("width", controlBoxSize*2)
.attr("height", controlBoxSize*2);
var additionalOffset = controlBoxSize*2;
g_box
.attr("transform", function (d,i){
if( i > this.index) { // want to do something like this, what to use for "this.index"?
return "translate("+(width - 100)+","+((controlBoxSize+5)+i*(controlBoxSize+5)+additionalOffset)+")"
} else {
return "translate("+(width - 100)+","+((controlBoxSize+5)+i*(controlBoxSize+5))+")"
}
})
})
What I want to do is move rect 4 and 5 on mouseover so they slide out of the way and do not overlap rect 3 which is expanding.
So is there a way to detect the data index "i" of "this" rect in my mouseover event so that I could implement some logic to adjust the translate() of the other rect accordingly?
You can easily get the index of any selection with the second argument of the anonymous function.
The problem here, however, is that you're trying to get the index in an anonymous function which is itself inside the event handler, and this won't work.
Thus, get the index in the event handler...
selection.on("mouseover", function(d, i) {
//index here ---------------------^
... and, inside the inner anonymous function, get the index again, using different parameter name, comparing them:
innerSelection.attr("transform", function(e, j) {
//index here, with a different name -----^
This is a simple demo (full of magic numbers), just to show you how to do it:
var svg = d3.select("svg");
var data = d3.range(5);
var groups = svg.selectAll("foo")
.data(data)
.enter()
.append("g");
var rects = groups.append("rect")
.attr("y", 10)
.attr("x", function(d) {
return 10 + d * 20
})
.attr("width", 10)
.attr("height", 100)
.attr("fill", "teal");
groups.on("mouseover", function(d, i) {
d3.select(this).select("rect").transition()
.attr("width", 50);
groups.transition()
.attr("transform", function(e, j) {
if (i < j) {
return "translate(40,0)"
}
})
}).on("mouseout", function() {
groups.transition().attr("transform", "translate(0,0)");
rects.transition().attr("width", 10);
})
<script src="https://d3js.org/d3.v4.min.js"></script>
<svg></svg>
PS: don't do...
g_box.selectAll("rect").on("mouseover", function(d, i){
... because you won't get the correct index that way (which explain your comment). Instead of that, attach the event to the groups, and get the rectangle inside it.
I'm pretty sure d3 passes in the index as well as the data in the event listener.
So try
.on("mouseover", function(d,i)
where i is the index.
Also you can take a look at a fiddle i made a couple months ago, which is related to what you're asking.
https://jsfiddle.net/guanzo/h1hdet8d/1/
You can find the index usign indexOf(). The second argument in the event mouseover it doesn't show the index in numbers, it shows the data info you're working, well, you can pass this info inside indexOf() to find the number of the index that you need.
.on("mouseover", (event, i) => {
let index = data.indexOf(i);
console.log(index); // will show the index number
})

D3 zoom pan stutter

I'm experiencing 'stutter' with the D3 drag behavior.
Seems to be a similar problem to "Stuttering" drag when using d3.behavior.drag() and transform
However the solution does not seem to work for the zoom behavior.
Here is an example of the issue: (try dragging the rectangle)
http://jsfiddle.net/EMNGq/109/
blocks = [
{ x: 0, y: 0 }
];
var translate_var = [0,0];
zoom_var = d3.behavior.zoom()
.on("zoom", function(d) {
d.x = d3.event.x;
d.y = d3.event.y;
draw();
});
svg = d3.select("body")
.append("svg:svg")
.attr("width", 600)
.attr("height", 600);
function draw() {
g = svg.selectAll("g")
.data(blocks);
gEnter = g.enter().append("g")
.call(zoom_var);
g.attr("transform", function(d) { return "translate("+translate_var[0]+","+translate_var[1]+")"; });
gEnter.append("rect")
.attr("height", 100)
.attr("width", 100);
}
draw()
You zoom in or drag the element, but then translate the same element. Because the translation is relative, it results in this stuttering.
As stated in the documentation for Zoom Behavior:
This behavior automatically creates event listeners to handle zooming and panning gestures on a container element. Both mouse and touch events are supported.
Contrast it to the documentation for Drag Behavior:
This behavior automatically creates event listeners to handle drag gestures on an element. Both mouse events and touch events are supported.
Your solution is inverse to the similar question. Call your zoom function on the container.
svg = d3.select("body")
.append("svg:svg")
.attr("width", 600)
.attr("height", 600)
.call(zoom_var);
Here's the demo.
You might also be interested in the actual zoom. To do that simply add the scale to your transform rule. Here's the demo with zoom enabled.

D3.js: circle color change on title mouseover doesn't work

I'm appending some text to D3.js circles and want the circles to change color mouseover, also on mouseover on the text.
Currently the circles do change color on mouseover, but when hovering over the text, the circle mouseover doesn't work anymore (logical: I'm hovering over the text). How do I get the circles to also change color when hovering over the text?
My code (gnode is a earlier defined circle):
var label = gnode.append("text")
.text(function(d) { return d.key ; })
.attr("font-size", function(d) {return 12 + d.value[0]/4})
.attr("text-anchor", "middle")
.call(force.drag)
.on("mouseover", function(d){
this.style.cursor='pointer';
d3.select( "#" + d.key.toLowerCase().replace(/ /g, '_'))
.attr("id", "none")
.classed("mouse_over",true)
.classed("mouse_out",false);
thanks
You can achieve this by simply putting all the elements belonging together inside a group. Then attach the mouse events to the group instead of the elements themselves.
First create svg element and append data:
var svg = d3.select("#main")
.append("svg")
.attr("id", "svgElement")
.attr("width", width)
.attr("height", height);
var svgg = svg.selectAll("g.myGroup")
.data(myData)
.enter()
.append("g");
Then add your elements via the each function:
svgg.each(function (d, i) {
selection = d3.select(this);
// ... append to this selection
});
Now attach mouse events to the group:
svgg.on("mouseover", function(d) {
d3.select(this) // Select and do something
});
You can find the working fiddle here:
http://jsfiddle.net/77XLD/1/
Note that the event fires when either moving over the line of the circle and also when hovering over the text.

Resources