selection.join behavior when each node has children

Viewed 53

I follow the excellent https://observablehq.com/@d3/selection-join to move to join paradigm.

Works fine until I want to make each node more complex (a group with a text inside). The root group is updated but not the child one.

Code snippet

see https://codepen.io/daohodac/pen/JjdJWEV (letters1 is preloaded. click on update to load the letters2 dataset)

const letters1 = "abcdefghijkl".split("").map((l,i) => ({l:l, color:'green', shift:0}));
const letters2 = "abcdefghijklmnopqrstuvwxyz".split("").map((l,i) => ({l:l, color:'red', shift:i*5}));

In this piece of code, the data is correctly updated on the root node (<g> shifts according to the new value of the shift) but the color of the inner text does not change on the preloaded letters. The data is correctly bound to <g>. How can I tell d3 to use it also on the <text> child element?

1 Answers

That's actually the expected behaviour. In a nutshell: you're updating the data for the parent (<g>) elements, but you're never updating the data for the children (<text>) elements.

Those text elements inherited the first dataset when you called fillNode, which contains the append method that propagates the data. However, fillNode is only called for the "entering" groups. Therefore, the texts inside the groups that are just updated never receive the new data, despite their parent's data being correctly updated.

You can see this clearly if you just log the data for the <text> elements after you update the data for the groups. Check the console, the green colour is still there for a to l:

//letters to load
const letters1 = "abcdefghijkl".split("").map((l, i) => ({
  l: l,
  color: 'green',
  shift: 0
}));
const letters2 = "abcdefghijklmnopqrstuvwxyz".split("").map((l, i) => ({
  l: l,
  color: 'red',
  shift: i * 5
}));

//initialize the root variable
const svg = d3.selectAll("#svg-holder");

//fills the child text element (only in enter)
const fillNode = (g) => {
  return g.append('text')
    .attr("x", (d, i) => i * 16)
    .text(d => d.l);
}

// binds the letters1 to the nodes
svg
  .selectAll("g.node")
  .data(letters1)
  .join(enter => enter
    .append('g')
    .classed('node', true)
    .attr('transform', d => `translate(0, ${d.shift})`)
    .call(fillNode))
  .attr('transform', d => `translate(0, ${d.shift})`)
  .selectAll('text').attr('fill', d => d.color);

//button callback
const update = () => {
  // binds the letters2 to the nodes
  svg
    .selectAll("g.node")
    .data(letters2)
    .join(enter => enter
      .append('g')
      .classed('node', true)
      .attr('transform', d => `translate(0, ${d.shift})`)
      .call(fillNode))
    //changing the position works fine (root element)
    .attr('transform', d => `translate(0, ${d.shift})`)
    //changing the color is not effective (child element)
    .selectAll('text')
    .each(d => console.log(d))
    .attr('fill', d => d.color);

}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.15.0/d3.min.js"></script>
<button onclick="update()">update</button>
<div>begin</div>
<svg id="svg-holder" width=1300 height=330 viewbox="0 -20 1300 330"></svg>
<div>end</div>

The simplest solution is just setting the fill to the groups, and the texts will inherit it. But that doesn't answer your actual question: what's going on in this nested update. For that, the most adequate solution is creating a nested enter/update/exit selection for the texts. As this is too complicated and also, given your data, unnecessary, you can simply pass the current data for the texts, with a short function like this:

const updateNode = (g) =>{
    g.select("text").datum(d => d);
};

Here is the demo:

//letters to load
const letters1 = "abcdefghijkl".split("").map((l, i) => ({
  l: l,
  color: 'green',
  shift: 0
}));
const letters2 = "abcdefghijklmnopqrstuvwxyz".split("").map((l, i) => ({
  l: l,
  color: 'red',
  shift: i * 5
}));

//initialize the root variable
const svg = d3.selectAll("#svg-holder");

//fills the child text element (only in enter)
const fillNode = (g) => {
  return g.append('text')
    .attr("x", (d, i) => i * 16)
    .text(d => d.l);
}

const updateNode = (g) => {
  g.select("text").datum(d => d);
};

// binds the letters1 to the nodes
svg
  .selectAll("g.node")
  .data(letters1)
  .join(enter => enter
    .append('g')
    .classed('node', true)
    .attr('transform', d => `translate(0, ${d.shift})`)
    .call(fillNode))
  .attr('transform', d => `translate(0, ${d.shift})`)
  .call(updateNode)
  .selectAll('text').attr('fill', d => d.color);

//button callback
const update = () => {
  // binds the letters2 to the nodes
  svg
    .selectAll("g.node")
    .data(letters2)
    .join(enter => enter
      .append('g')
      .classed('node', true)
      .attr('transform', d => `translate(0, ${d.shift})`)
      .call(fillNode))
    //changing the position works fine (root element)
    .attr('transform', d => `translate(0, ${d.shift})`)
    .call(updateNode)
    //changing the color is not effective (child element)
    .selectAll('text').attr('fill', d => d.color);

}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.15.0/d3.min.js"></script>
<button onclick="update()">update</button>
<div>begin</div>
<svg id="svg-holder" width=1300 height=330 viewbox="0 -20 1300 330"></svg>
<div>end</div>

Related