Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How/when do event listeners get attached in d3.js?

I am trying to make an SVG editor of sorts. Long story short, I need to attach mouse events to <g> elements at a specific depth within a given SVG. For various reasons I cannot know the ID ahead of time. The SVG is huge and will have hundreds if not thousands of elements.

d3.selectAll("svg > g > g > g").select("g").on("mouseover", function() {
    console.log("mouseover");
  }).on("mouseout", function() {
    console.log("mouseout");          
  }).on("click", function() {
    console.log("clicked");
  });

This code works, but it takes a long time before it gets started. Let's say I have ten such elements that will match that particular selection. It seems like each second after page load another one of the 10 actually gets the mouse events attached. I am wondering if I can get a console event printed each time d3 attaches an event or how I can tell if d3 is done attaching everything it needs to attach.

Basically this JSFiddle needs to load the mouse events much more quickly. If you wait a few seconds you will see more and more boxes working.

like image 244
Ben Avatar asked Jan 20 '17 16:01

Ben


People also ask

How does event listener work in JavaScript?

The addEventListener() is an inbuilt function in JavaScript which takes the event to listen for, and a second argument to be called whenever the described event gets fired. Any number of event handlers can be added to a single element without overwriting existing event handlers. Syntax: element.

Does event listener works only once?

Using the once option We can pass an object as an argument to the addEventListener method and specify that the event is only handled once. This is achieved by passing the property once to the object. If we set once to true, the event will only be fired once.

What happens to event listeners when element is removed?

In modern browsers, if a DOM Element is removed, its listeners are also removed from memory in javascript. Note that this will happen ONLY if the element is reference-free. Or in other words, it doesn't have any reference and can be garbage collected. Only then its event listeners will be removed from memory.


2 Answers

tl;dr

As it turns out, this is an intricate variation of the infamous pointer-events vs. fill hassle. The event handlers are in fact attached to the <g> elements right away. They are, however, not executed for some time, because the events will not get through to these elements most of the time. Setting pointer-events: all does easily fix this issue.

Apart from the technical issues this is a perfect example of why you should provide a minimal example, where things are stripped down to the bare minimum. The sheer amount of code made it unnecessarily hard to attack. The following snippet contains just enough code to demonstrate the issue:

d3.select("g").on("mouseover", function() {
  // The difference between below log entries shows, that the event was
  // targeted at another element and bubbled up to this handler's element.
  console.dir(d3.event.target.tagName);   // <rect>: actual target for this event
  console.dir(this.tagName);              // <g>:    element this handler is attached to

  d3.select(this).select("rect")
    .style("fill", "orange");
});
rect {
  stroke: red;
  stroke-width: 0.2;
  stroke-dasharray: 1.5 1.5;
  fill:none;
}
<script src="https://d3js.org/d3.v4.js"></script>
<svg width="300" height="300">
  <g>
    <rect x="20" y="20" width="200" height="200"/>
  </g>
</svg>

Analysis

When a browser determines which element will become a target of a pointer event, it will do something called hit-testing:

16.5.1 Hit-testing

Determining whether a pointer event results in a positive hit-test depends upon the position of the pointer, the size and shape of the graphics element, and the computed value of the ‘pointer-events’ property on the element.

The above sentence contains two pieces of vital information for your issue:

  1. Only graphic elements can become direct targets of pointer events, whereas mere <g> elements alone cannot by itself be targets of these events. The events may, however, bubble and eventually reach that group. From within your event handlers you can log the actual target of the event as referenced in d3.event.target as well as this, which points to the element, this handler was attached to:

    .on("mouseover", function() {
      // The difference between below log entries shows, that the event was
      // targeted at another element and bubbled up to this handler's element.
      console.log(d3.event.target);   // <path>: actual target for this event
      console.log(this);              // <g>:    element this handler is attached to
    
      d3.select(this).select("path")
        .style("fill", "orange");
    })
    

    As you can see in this JSFiddle, these will always differ. This is relevant for your scenario, because you register the handler functions on the groups. This way, the handlers will only get executed if a graphics child element of the group becomes a pointer event's target with the event bubbling up to the group itself. This, on its own, is not much of a problem, but, in conjunction with the next point, this explains, why your set-up is not working.

  2. The pointer-events property determines, "whether or when an element may be the target of a mouse event". Because this property is never set throughout your code, the default kicks in, which is visiblePainted defined as follows (emphasis mine):

    The element can only be the target of a mouse event when the visibility attribute is set to visible and when the mouse cursor is over the interior (i.e., 'fill') of the element and the fill attribute is set to a value other than none, or when the mouse cursor is over the perimeter (i.e., 'stroke') of the element and the stroke attribute is set to a value other than none.

    As others have noted in the comments the relevant <path> elements within your group all feature the class st8 which defines fill: none, whereby preventing them from becoming an event target when hovering their interior, i.e., fill. When these paths cannot become target for the pointer events, there is no event, that could bubble up to your group, which renders the event listeners useless.

    If a listener was executed the first time on an element (why this can happen is explained below, so bear with me for the moment), this problem resolves itself by setting the fill property on the path, whereby making it a legitimate target for pointer events. This is why the handlers will continue functioning when they have first come to life.

    Side note: This effect is so powerful, that it will even influence the way the dev tools deal with these elements in Chrome and Firefox. When you try to inspect an element, that has fill set to none, by right clicking on it, the dev tools will open up referencing the root <svg> element instead of the element you clicked on, because the latter was not the event's target. Try this, in contrast, with an element where the event handler is already working, so to speak, and it will open the dev tools for exactly this very element.

Solution

The easy solution to this is to allow for pointer events to occur on the interior, i.e. fill, of the paths by explicitly setting the property to all:

The element can only be the target of a mouse event when the pointer is over the interior (i.e., fill) or the perimeter (i.e., stroke) of the element. The values of the fill, stroke and visibility attribute do not affect event processing.

This can best be done right before registering the event handlers as in my updated JSFiddle:

d3.selectAll("svg > g > g").select("g").select("g")
  .attr("pointer-events", "all")
  .on("mouseover", function() {
    //...
  }

Why does it work sometimes and why the delays?

The above provides a proper analysis and a working solution, but, if you give it some time to sink in, there still remains the question, why on earth the handlers appear to be registered or, at least, to be activated with such delays. Pondering even more on this, it turns out all information to understand the issue is already contained in my explanation.

As I said above, the <path> elements will actually be the event targets, not the groups. With the pointer-events property defaulting to visiblePainted they are not completely unreachable for pointer events as can be seen re-reading above mentioned specification:

[…] or when the mouse cursor is over the perimeter (i.e., 'stroke') of the element and the stroke attribute is set to a value other than none.

Altough the infamous class st8 sets stroke: ff0000 (which obviously is other than none), it specifies stroke-width:0.24 which is a pretty thin line. Additionally being dashed, it turns out, it is hard to hit the line at all. If you actually do hit it, though, it will cause the path to become an event target with the event bubbling to the group, eventually executing the event handler. This effect can be demonstrated by setting the stroke-width to a larger value making it easier to hit the path:

.st8 {
  fill:none;
  stroke:#ff0000;
  stroke-dasharray:1.68,1.2;
  stroke-linecap:round;
  stroke-linejoin:round;
  stroke-width:2      /* Set to 2 to make it easier to hit */
}

Have a look at this JSFiddle for a working demo.

Even without setting pointer-events: all this will work, because the lines are now wide enough to be hit by the pointer. Because the fat lines are ugly and will break the fine layout, this is more of a demonstration than a real solution, though.

like image 180
altocumulus Avatar answered Oct 10 '22 11:10

altocumulus


This is a very interesting problem, I managed to make it work, but I have no explanation to why this works. Would appreciate if someone with in-depth knowledge would explain this.

Slow:

var targetElements = d3.selectAll("svg > g > g").select("g").select("g").select("path");
targetElements.on("mouseover", function() {
  d3.select(this)
    .style("fill", "orange");
}).on("mouseout", function() {
  d3.select(this)
    .style("fill", "BLUE");
}).on("click", function() {
  d3.select(this)
    .style("fill", "green");
});

Fast:

var targetElements = d3.selectAll("svg > g > g").select("g").select("g").select("path");
targetElements.style('fill', 'white'); // Black magic - comment this out and the event handler attachment is delayed alot
targetElements.on("mouseover", function() {
  d3.select(this)
    .style("fill", "orange");
}).on("mouseout", function() {
  d3.select(this)
    .style("fill", "BLUE");
}).on("click", function() {
  d3.select(this)
    .style("fill", "green");
});

The difference is only in applying fill to the elements before I attach event handlers to them - .style("fill", "white").on("mouseover",

The Fiddle to play around - https://jsfiddle.net/v8e4hnff/1/

NOTE: Also tried to implement with JS native selectors and event handler attachment on the SVG elements, that was very little faster than D3. Behavior is the same on IE11 and Chrome.

As said above, if someone can explain the behavior, please do!

like image 20
tiblu Avatar answered Oct 10 '22 10:10

tiblu