0

Working through the excellent Interactive Data Visualization for the Web book and have created (a monstrosity of a) script to create an interactive bar chart that:

  1. Adds a new bar to the end when clicking on the svg element
  2. Generates a new set of 50 bars when clicking on the p element

I have added a mouseover event listener to change the color of the bars when hovering over. The problem is that bars added via 1. above are not changing color. As far as I can tell, the bars are getting selected properly, but for whatever reason, the mouseover event is never being fired for these bars:

svg.select(".bars").selectAll("rect")
  .on("mouseover", function() {
    d3.select(this)
        .transition()
        .attr("fill", "red");
  })

Thanks in advance for your help, it is always greatly appreciated.

Here is the full code:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Interactive Data Visualization for the Web Program-Along</title>
    <style>
      /* axes are made up of path, line, and text elements */
      .axis path,
      .axis line {
        fill: none;
        stroke: navy;
        shape-rendering: crispEdges;
      }

      .axis text {
        font-family: sans-serif;
        font-size: 11px;
        /* color is CSS property, but need SVG property fill to change color */
        fill: navy;
      }
    </style>
    <script src="http://d3js.org/d3.v3.min.js" charset="utf-8"></script>
  </head>
  <body>
    <p>Click on this text to update the chart with new data values.</p>

    <script type="text/javascript">

      var n = 50;
      var domain = Math.random() * 1000;

      function gen_data(n, domain) {
        var d = [];
        for (var i = 0; i < n; i++) {
          d.push(
                  { id: i, val: Math.random() * domain }
          );
        }
        return d;
      }

      // define key function once for use in .data calls
      var key = function(d) {
        return d.id;
      };

      var dataset = gen_data(n, domain);

      // define graphic dimensions
      var w = 500, h = 250, pad = 30;

      // get input domains
      var ylim = d3.extent(dataset, function(d) {
        return d.val;
      });

      // define scales
      var x_scale = d3.scale.ordinal()
          .domain(d3.range(dataset.length))
          .rangeRoundBands([0, w - pad], 0.15);

      var y_scale = d3.scale.linear()
          .domain([ylim[0], ylim[1] + pad])  // could have ylim[0] instead
        // range must be backward [upper, lower] to accommodate svg y inversion
          .range([h, 0]);  // tolerance to avoid clipping points

      var color_scale = d3.scale.linear()
          .domain([ylim[0], ylim[1]])
          .range([0, 255]);

      // create graphic
      var svg = d3.select("body").append("div").append("svg")
          .attr("width", w)
          .attr("height", h);

      svg.append("g")
          .attr("class", "bars")
          .selectAll(".bars rect")
          .data(dataset)
          .enter()
          .append("rect")
          .attr({
            x: function(d, i) {
              return x_scale(i) + pad;
            },
            y: function(d) {
              return y_scale(d.val);
            },
            width: x_scale.rangeBand(),  // calculates width automatically
            height: function(d) { return h - y_scale(d.val); },
            opacity: 0.6,
            fill: function(d) {
              return "rgb(50, 0, " + Math.floor(color_scale(d.val)) + ")";
            }
          });

      // add axes
      var yAxis = d3.svg.axis()
          .scale(y_scale)  // must be passed data-to-pixel mapping (scale)
          .ticks(3)  // optional (d3 can assign ticks automatically)
          .orient("left");

      // since function, must be called
      // create <g> to keep things tidy, to style via CSS, & to adjust placement
      svg.append("g")
          .attr({
            class: "axis",
            transform: "translate(" + pad + ",0)"
          })
          .call(yAxis);

      // add event listener for clearing/adding all new values
      d3.select("p")
          .on("click", function() {
            // generate new dataset
            dataset = gen_data(n, domain);

            // remove extra bars
            d3.selectAll(".bars rect")
              .data(dataset, function(d, i) { if (i < 50) { return d; }})
                .exit()
                .transition()
                .attr("opacity", 0)
                .remove();

            // update scales
            x_scale.domain(d3.range(dataset.length))
                .rangeRoundBands([0, w - pad], 0.15);
            ylim = d3.extent(dataset, function(d) {
              return d.val;
            });
            y_scale.domain([ylim[0], ylim[1] + pad]);

            // update bar values & colors
            d3.selectAll(".bars rect")
                .data(dataset)
                .transition()
                .duration(500)
                .attr("x", function(d, i) { return x_scale(i) + pad; })
                .transition()  // yes, it's really this easy...feels like cheating
                .delay(function(d, i) { return i * (1000 / dataset.length); })  // set dynamically
                .duration(1000)  // optional: control transition duration in ms
                .each("start", function() {
                  // "start" results in immediate effect (no nesting transitions)
                  d3.select(this)  // this to select each element (ie, rect)
                      .attr("fill", "magenta")
                      .attr("opacity", 0.2);
                })
                .attr({
                  y: function(d) { return y_scale(d.val); },
                  height: function(d) { return h - y_scale(d.val); }
                })
                .each("end", function() {
                  d3.selectAll(".bars rect")
                      .transition()
                      // needs delay or may interrupt previous transition
                      .delay(700)
                      .attr("fill", function(d) {
                        return "rgb(50, 0, " + Math.floor(color_scale(d.val)) + ")";
                      })
                      .attr("opacity", 0.6)
                      .transition()
                      .duration(100)
                      .attr("fill", "red")
                      .transition()
                      .duration(100)
                      .attr("fill", function(d) {
                        return "rgb(50, 0, " + Math.floor(color_scale(d.val)) + ")";
                      });
                });

            // update axis (no need to update axis-generator function)
            svg.select(".axis")
                .transition()
                .duration(1000)
                .call(yAxis);
          });

      // extend dataset by 1 for each click on svg
      svg.on("click", function() {
        // extend dataset & update x scale

        dataset.push({ id: dataset.length, val: Math.random() * domain });
        x_scale.domain(d3.range(dataset.length));

        // add this datum to the bars <g> tag as a rect
        var bars = svg.select(".bars")
            .selectAll("rect")
            .data(dataset, key);

        bars.enter()  // adds new data point(s)
            .append("rect")
            .attr({
              x: w,
              y: function(d) {
                return y_scale(d.val);
              },
              width: x_scale.rangeBand(),  // calculates width automatically
              height: function(d) { return h - y_scale(d.val); },
              opacity: 0.6,
              fill: function(d) {
                return "rgb(50, 0, " + Math.floor(color_scale(d.val)) + ")";
              }
            });

        // how does this move all the other bars!?
        // because the entire dataset is mapped to bars
        bars.transition()
            .duration(500)
            .attr("x", function(d, i) {
              return x_scale(i) + pad;
            });

      });

      // add mouseover color change transition using d3 (vs CSS)
      svg.select(".bars").selectAll("rect")
          .on("mouseover", function() {
            d3.select(this)
                .transition()
                .attr("fill", "red");
          })
          .on("mouseout", function(d) {
            d3.select(this)
                .transition()
                .attr("fill", function() {
                  return "rgb(50, 0, " + Math.floor(color_scale(d.val)) + ")";
                })
                .attr("opacity", 0.6);
          })
          // print to console when clicking on bar = good for debugging
          .on("click", function(d) { console.log(d); });

    </script>
  </body>
</html>

UPDATE:

Thanks to Miroslav's suggestion, I started playing around with different ways to resolve the issue and came across Makyen's answer to this related SO post.

While I imagine there is a more performant way to handle this, I have decided to rebind the mouseover event listener each time the mouse enters the svg element using the following code:

  svg.on("mouseover", mouse_over_highlight);

  // add mouseover color change transition using d3 (vs CSS)
  function mouse_over_highlight() {
    d3.selectAll("rect")
        .on("mouseover", function () {
          d3.select(this)
              .transition()
              .attr("fill", "red");
        })
        .on("mouseout", function (d) {
          d3.select(this)
              .transition()
              .attr("fill", function () {
                return "rgb(50, 0, " + Math.floor(color_scale(d.val)) + ")";
              })
              .attr("opacity", 0.6);
        })
      // print to console when clicking on bar = good for debugging
        .on("click", function (d) {
          console.log(d);
        });
  }
Community
  • 1
  • 1
circld
  • 632
  • 1
  • 7
  • 14

1 Answers1

2

Reason your event fires only for first bar and not the dynamic ones is because of the way you added your event listener.

Your way only puts events on elements already present on the page (they are in DOM structure). Any new elements will not have this event listener tied to them.

You can make a quick check for this by putting your code in function like

function setListeners() {
  svg.select(".bars").selectAll("rect").on("mouseover", function() {
    d3.select(this)
      .transition()
      .attr("fill", "red");
  })
}

After you add any new bars on the screen, add this function call and see if it works for all elements. If this is indeed the case, you need to write your event listener in a way it works for all elements, including dynamically added ones. The way to do that is to set the event to some of the parent DOM nodes and then checking if you are hovering on exactly the thing you want the event to fire.

Example:

$(document).on(EVENT, SELECTOR, function(){
    code;
});

This will put the event on the body and you can check then selector after it's triggered if you are over correct element. However it was a while since I worked with D3 and I'm not sure how D3, SVG and jQuery play together, last time I was doing it they had some troubles. In this case event should be mouseover, selector should be your rect bars and function should be what ever you want to run.

If everything else fails in case they won't cooperate, just use the function to set event listeners and call it every time after you dynamically add new elements.

Miroslav Saracevic
  • 1,446
  • 1
  • 13
  • 32
  • Just tested putting your mouseover event code into function and calling it from end of svg.on("click", function() {...}); and it seems to be working for me as you wanted it to work – Miroslav Saracevic May 06 '15 at 14:51
  • Thanks Miroslav. I knew I was overlooking something straightforward, but your explanation cleared it up. Cheers! – circld May 06 '15 at 23:22