Understanding D3 data binding key accessor

Viewed 257

I'm new to D3 and I'm doing a simple example trying to understand how the data binding works.

Basically I've an array of colors, a function for adda color and a function to remove a color from index. What is not working is the remove action. If I set 0 as index to remove, I see that D3 set the last element as element to remove. If I use the key accessor d => d, it works. I've a lot of question.

Here my code:

const data = {
  colors: ["Black", "White", "Brown"],
  addColor(color) {
    this.colors.push(color);
  },
  removeColorByIndex(index) {
    this.colors.splice(index, 1);
  }
};

const root = d3.select("#root");
const barsContainer = d3.select("#bars-container");
const addButton = d3.select("#add-button");
const removeButton = d3.select("#remove-button");

addButton.on("click", () => {
  const newColor = d3.select("#color-input").node().value;
  data.addColor(newColor);
  update();
});
removeButton.on("click", () => {
  const index = d3.select("#index-input").node().value;
  data.removeColorByIndex(index);
  update();
});

function update() {
  barsContainer
    .selectAll("div")
    .data(data.countries, (d, i) => {
      console.log({ i, d });
      return i;
    })
    .join(
      (enter) => {
        console.log("enter:", enter);
        return enter
          .append("div")
          .text((d) => d)
          .classed("bar", true)
          .classed("added", true);
      },

      (update) => {
        console.log("update:", update);
        return update.classed("update", true);
      },

      (exit) => {
        console.log("exit:", exit);
        return exit.classed("remove", true);
      }
    );

  console.log("divs", barsContainer.selectAll("div")["_groups"][0]);
}

update();
.bar {
  margin: 5px 0px 5px 0px;
  max-width: 200px;
  padding: 10px;
}
.added { background-color: lightgreen; }
.update { background-color: cornflowerblue; }
.remove { background-color: tomato; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id="root">
  <div>
    <div>
      <input type="text" id="color-input" />
      <button id="add-button">Add color</button>
    </div>
    <div>
      <input type="text" id="index-input" />
      <button id="remove-button">Remove color by index</button>
    </div>
  </div>
  <div id="bars-container"></div>
</div>

  1. Initially all the bars are green. That's because they are all in the enter selection.
  2. If I add a color using the UI button, then new bars appears, it's green and the old ones became blue.
  3. Then, If I try to remove the bar with index 0, the last bars became red, not the first one, why?

If I imagine the behind logic, it would be:

Step # Action Enter selection Update selection Exit selection Join return selection (enter + update)
0 / [black, white, brown] [] [] black, white, brown
1 Add 'Yellow' color [yellow] black, white, brown] [] black, white, brown, yellow] ?
2 Remove element with index 0 [] [yellow] [black] [yellow]

But it seems is not right, what I'm wrong?

I knwow that doing data(myData, (d, i) => i) is the same of data(myData) and that means that D3 is matching data/DOM nodes by index. So why if I look at the __data__ property of the binded elements, they have __data__ = black/white/brown and not __data__ = 0/1/2?

I'm very confused and I didn't find anything that can help me..

I read D3 documentation and also this question.

1 Answers

If I try to remove the bar with index 0, the last bar became red [exited], not the first one, why?

The identifier returned by the key function is not stored on either the DOM node nor the datum. It is evaluated each time you use .data(). Each time you use .data() the key function, if provided, is evaluated for each node in the selection (using the bound datum .__data__, which represents an item in the data array), then D3 iterates through the data array to find a datum to match to the node. If no matching node is found, a node is added to the enter selection with that datum. If after matching all the data, there are excess nodes, they are exited.

Your key function is (d,i)=>i - by removing the first item in your data array there is still an item in the data array with index 0. The data array item with index 0 is matched to the 0th node in the selection. So the first node cannot be exited: it still has a corresponding item in the data array.

Given your key function: this means that the datum originally at index 1 and paired to the node at index 1 is now the datum at index 0 (after splicing) and will be paired with the node at index 0

As your data array is one item shorter than it was, there is one excess node in the DOM. The last node has the highest index, for which there is no matching data array item, so the last node is exited. All nodes except for the last are in the update selection.

Normally you would use a key function to reference the data itself, not its position/index (as that might change due to sorting or other factors, which would then alter which datum is bound to which node). So if your data consists of unique colour names, you'd want to use: .data(data,d=>d) (note: if d is an object, the key function should return a string). This way the same datum is paired to the same node regardless of index. Consequently, splicing a datum from the array will remove the corresponding node it is bound to.

I've created two attempts to visualize the key function, one with a key dependent on the data, one based on a key that is the index. If you look closely when the index key is being used to match datum and element, the datum bound to the element changes: the key is independent of the datum.

Key based on datum

Key based on index

The bl.ocks are a bit rough, I'll probably tweak and incorporate into the answer body here.

Related