General Update Pattern and Animation

Creating animations and using ID functions

Code, Recording

  1. In these notes, we consider a more advanced example of D3’s general update pattern. We’ll first look at how this pattern is used in the animation below. Note that since circles are continually being added, modified, and removed, we will have to use enter, update, and exit selections.
  1. Before we can use the general update pattern, we need to keep track of a continually evolving data array. Each array element will be associated with one circle, and the value encoding its radius will be changed from step to the next.
      [{x:..., y: ..., r: ..., and other circle characteristics},
       {x: ...}
      ]
    
  2. Let’s consider the logic for creating and updating this data array, before we discuss how exactly we would visualize it. The code block below creates a single javascript object parametrizing one circle.
    // example use new_point(200, 200, 50)
    function new_point(width, height, max_radius) {
     let generator = d3.randomUniform();
     return {
       x: width * generator(),
       y: height * generator(),
       r: 2,
       max_radius: max_radius * generator(),
       rate: 1 + 0.1 * generator()
      }
    }
    

    The x and y positions are uniformly chosen across the window. All circles start with a radius of two pixels, but some are allowed to grow larger than others (max_radius). Finally, the rate at which their size increases is itself random, given by rate, a number distributed uniformly from 1 (no increase across frames) to 1.1 (10% increase with each frame).

  3. We store the data as an array of these objects. With each frame, we add a single new circle to the array using the function above. Any circles that are already on the screen have their radius increased, and if it grows beyond max_radius, the circle is removed from the array. This logic is reflected in the function below.
     function update_data(rain) {
       rain = rain.concat(new_point(900, 200, 50));
       rain = rain.map(d => { d.r *= d.rate;  return d});
       return rain.filter(d => d.r < d.max_radius);
     }
    
  4. Even without any visualization, we can see how the radius of each circle is increasing every time update_data is called. For example, copying this block into the console will show how the radius of the first circle increases across 10 frames.
     let rain = []
     for (let i = 0; i < 10; i++) {
       rain = update_data(rain);
       console.log(rain[0]["r"]);
     }
    
  5. Now, let’s consider how we can visualize a dataset that’s evolving in this way. Let’s create a selection and bind the rain array to it,
     let circ = d3.select("svg")
       .selectAll("circle")
       .data(rain)
    
  6. If there is a new element in the array relative to the previous frame, we need to append a new circle that represents it, and since the radius for all elements in the array will have been changed, we need to update the radius property across the entire selection. Finally, since some circles will have been filtered out (their radius got too large), we need to exit their corresponding tags. This is concisely captured by the .join() call below, though we could also have used .enter(), .exit(), and full d3.selectAll() selections instead.
    let circ = d3.select("svg")
      .selectAll("circle")
      .data(rain)
      .join(
         enter => enter.append("circle")
                       .attrs({ cx: d => d.x, cy: d => d.y }),
         update => update.attr("r", d => d.r),
         exit => exit.remove()
      )
    
  7. We can capture the entire general update pattern within a function. Using the d3.timer() function to call it every 100 milliseconds, we get an animated view of the rain array.
     function update_vis() {
       rain = update_data(rain);
       let circ = d3.select("svg")
         .selectAll("circle")
         .data(rain)
         .join(
           enter => enter.append("circle")
             .attrs({ cx: d => d.x, cy: d => d.y }),
           update => update.attr("r", d => d.r),
           exit => exit.remove()
         )
     }
    
     d3.interval(update_vis, 100);
    
  8. There is a subtle bug in this implementation. Can you see what it is? The issue is that sometimes circles in the middle of the array will have their radii grow too large, and they will be filtered away. However, our data bind only knows that the array has gotten shorter, so it exits the last circle tags, even if those weren’t the ones that should be removed. This issue is illustrated in the sketch below, This bug leads to the choppy appearance in this version of the animation.
  1. To fix this, we can use a data bind with an ID function. This associates each appended circle with a specific ID, which D3 can then use to remove the element on the DOM that matches the array element that has actually been removed. Conceptually, we can change the earlier sketch to the corrected version below.

  2. In our case, we create a id attribute for each circle (we guarantee its uniqueness with an id global variable).

    function new_point(width, height, max_radius) {
      let generator = d3.randomUniform();
      id += 1;
      return {
        id: id // this is the line that we added
        x: width * generator(),
        y: height * generator(),
        r: 2,
        max_radius: max_radius * generator(),
        rate: 1 + 0.1 * generator(),
      }
    }
    

    and then we refer to this id during the data bind

    let circ = d3.select("svg")
      .selectAll("circle")
      .data(rain, d => d.id)
    

    Now, when a circle in the middle of the array is removed, the associated element from the visualization will also be exited.