🏠 back to Observable

Preserving data in callbacks

Suppose I have a group of elements (say, circles) and each circle
has an on('click',handler) which causes a transition to invisibility.
I want to track the total number of clicks across the all elements. Can someone point me to an example where state is preserved among
callbacks in this way?

Try this pattern. It sets the ‘counter’ attribute associated with the ‘container’ svg element containing the circles:

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<script>

		  function addOne(){
			const container = document.getElementById("container");
			container.setAttribute("counter", parseInt(container.getAttribute("counter")) + 1);
		  }
		</script>		
	</head>

	<svg id="container" height="100" width="400" counter="0" >
	  <circle id="c1" cx="50" cy="50" r="40"  fill="red" onclick="addOne()" />
	  <circle id="c2" cx="200" cy="50" r="40"  fill="blue" onclick="addOne()" /> 
	</svg> 

</html>

Thanks! I found a slightly different approach, using d3.local().

I created a local variable attached to the svg container and incremented that
variable on click of the container. This all helped me understand what the
local variables are for and also how to deal with events percolating up through the DOM tree.

I suspect that d3.local variables are essentially the same as what you are proposing.

Both are options depending on the need.

If you look at the d3 documentation for d3.local it states:

If you are just setting a single variable, consider using selection .property:

That is the d3 framework equivalent to the non-framework .setAttribute method above.

Like jQuery’s .data(), d3.local() extends the Document Object Model (DOM) for the specific node, as opposed to setting the value of an element’s (a specific type of node) attribute.

The d3.local pattern has a big advantage in extending the DOM over setting an attribute value; as seen in the example referenced in the documentation.

The ‘locals’ in that example are setting references to functions that are ‘localized’ to appended ‘g’ nodes. If you were to attempt to set/reference a function in an element’s attribute with the .setAttribute method, the function would first be converted to a string and that string is the value associated with the attribute.

With the ‘locals’ (as with the jQuery.data’s) what is stored is a fully ‘functioning’ JavaScript object, not its string equivalent. That explains the use of the parseInt method in the pattern above.

Here’s a codepen illustrating this: https://codepen.io/ubermario/pen/BajdVGZ?editors=1000

Some other options:

  • count the clicks at a parent element (assumes that events aren’t stopped from propagating):
    parent.on('click', () => {
      if(d3.event.target.matches('a.myLink')) { /* ... */ }
    });
    
  • Return the click handler from a factory function that stores the count inside a closure:
    const onclick = (() => {
      let count = 0;
      return () => { count++ }
    }());
    // ...
    links.on('click', onclick);
    
  • like the above, but use selection.call:
    links.call((selection => {
      let count = 0;
      selection.on('click', () => count++);
    });

To make sure I understand the second two examples, when you refer to the links object you mean a parent of all of the circles, right? If I have a bunch of circles, and each one gets .on('click',onclick) then if I understand correctly the counter variable will be local to each circle.

It will be local to the event handler.

I’ve adapted a D3 notebook to demonstrate each example (check your dev tools console):

The relevant excerpt:

  // Example 1
  const onclick = (() => {
    let count = 0;
    return () => {
      if(d3.event.target.matches('circle')) {
        console.log('Count A', ++count);
      }
    };
  })();
  
  const circle = svg.append("g")
    .on('click.example1', onclick)
    .selectAll("g")
    .data(circles)
    .join("g")
      .attr("transform", d => `translate(${d.x},${d.y})`)
      .call(g => g.append("circle").attr("r", radius).attr("fill", '#ccc'))
      .call(g => g.append("circle").attr("r", 2.5))
  
      // Example 2
      .on('click.example2', (() => {
        let count = 0;
        return () => {
          console.log('count B', ++count);
        };
      })())
  
      // Example 3
      .call(selection => {
        let count = 0;
        return selection.on('click.example3', () => {
          console.log('count C', ++count );
        });
      });

I deleted my reply here because I failed to notice that
you call the factory function in the .on('click',..).
So my prior remark was silly. I think I see now how this all works.