1

Getting reacquainted with hos D3JS works and, in particular .selectAll/.data/.enter; learning how to nest data.

I have this working demo:

svg = d3.select('body')
  .append('svg')
  .attr('width',  640)
  .attr('height', 480);
svg.selectAll('text')
  .data( [ 'hello', 'world' ] )
  .enter()
    .append('text')
    .attr('x', 10)
    .attr('y', function( d, i ) {
      return 20 + i * 20;
    })
    .selectAll('tspan')
    .data( function( d, i ) { // d is from the first data
      return Array.from(d); // if needed, could return an array or object that includes the initial value, too.
    })
    .enter()
      .append('tspan')
      .attr('class', function( d, i ) {
        console.log( 'tspan class:', d, i );
        if ( ['a', 'e', 'i', 'o', 'u', 'y'].includes(d) ) {
          return 'vowel';
        }
      })
      .text( function( d, i, foo ) { // d is from the second data
        console.log( 'tspan text:', d, i, /*foo*/ );
        return d;
      })
    .exit()
    .attr('class', 'strong') // this will set strong on <tspan>, but I wanted it on <text>
    ;

(See it on on Codepen: D3JS selectAll SVG nested.)

Notice how we have two data(), with the second (for <tspan>s) nested inside the first one (for <text>s).

What I'm trying to do:

  • I want to set the strong class attribute on <text>.
  • I thought exit() would get me out of the "scope"/nest of working with the tspans… but I'm wrong.
  • If I comment out exit(), then the strong class attribute is set on the <tspan>s… while I want it on the parent <text>!

How can I achieve that, besides:

  • Moving the line up (before the second data()).
  • Using a separate statement (svg.selectAll('text').attr('class', 'strong');)

I could use one of these two options and in this example it would be trivial…
But I want to know if it's possible to get out of nested selections and, if so, how?

I hope this was clear enough; if not please comment and I'll clarify :)

Fabien Snauwaert
  • 4,995
  • 5
  • 52
  • 70
  • I tried a lot of things but it doesn't seem possible without using workarounds, hacks, anti-patterns or the two solutions you mentioned. – Alex L Feb 17 '20 at 17:42
  • 1
    For better or for worse, I realized I'd asked in substance the same question two years ago: [D3JS: appending simple nested HTML in a single command](https://stackoverflow.com/a/50492373/1717535). The short answer being: "No, you can't get out of nesting." However, you can use variables to break down the code and keep it readable; see [this answer](https://stackoverflow.com/a/50492373/1717535), in particular the last code snippet. – Fabien Snauwaert Feb 19 '20 at 21:12

1 Answers1

0

You could think about modifying your original data and that way you could just use one data binding and one update pattern. Within that pattern, you could check/use the word but also check each letter of the word (you could also just do this on the fly later):

const test_data = [ 'hello', 'world', 'wthtvwls' ];

const modified_data = test_data.map((item,index) => {
  return {
    word: item,
    array: Array.from(item)
  }
});

console.log(modified_data);

Then using this data (demo - https://codepen.io/Alexander9111/pen/qBdZrLv):

svg = d3.select('body')
  .append('svg')
  .attr('width',  640)
  .attr('height', 480);
svg.selectAll('text')
  .data( modified_data )
  .enter()
    .append('text')
    .attr('x', 10)
    .attr('y', function( d, i ) {
      return 20 + i * 20;
    })
    .html(function(d) {      
      return d.letters.map((letter,index) => {
        if (['a', 'e', 'i', 'o', 'u', 'y'].includes(letter)){
          return '<tspan class="vowel">' + letter + '</tspan>';
        } else{
          return '<tspan>' + letter + '</tspan>';
        }        
      }).join("");
    })   
    .attr('class', function( d, i ) {
        for (letter of d.letters){
          console.log('letter', letter);
          if (['a', 'e', 'i', 'o', 'u', 'y'].includes(letter)) {
            return 'strong';
          }
        }
      })
    .exit();

Note the use of the .html() function and then .map() within that using our current data.letters in the normal update pattern to add either '<tspan class="vowel">' or '<tspan>' (no class vowel).

https://codepen.io/Alexander9111/pen/qBdZrLv

But I am not sure if you want such a specific use case or a more general answer to the nested update pattern? If so then maybe this block helps? - https://bl.ocks.org/mpbastos/bd1d6763d34ac5d3ce533a581b291364

UPDATE - I realize I misread the question the first time

So it is now clear to me that my one-time solution is no more helpful than the other two solutions suggested by @Fabien (OP) (Moving the line up (before the second data()) or Using a separate statement svg.selectAll('text').attr('class', 'strong');)

I tried patterns such as:

svg.selectAll('text')
    .data( test_data )
    .enter()
    .append('text')
    .attr('x', 10)
    ...
    .selectAll('tspan')
    .data( ...)
    .enter()
      .append('tspan')
      .attr('class', 'example')
    .exit()
    .select(this.ParentNode)
    .attr('class', 'example')

And also with the .select(this.ParentNode) before the .exit() but neither worked.

The only thing that works but it is also a workaround and probably an anti-pattern is this - hijack the attr function to "grab" parent outside nested update :

svg = d3.select('body')
  .append('svg')
  .attr('width',  640)
  .attr('height', 480);
text = svg.selectAll('text')
    .data( test_data )
    .enter()
    .append('text')
    .attr('x', 10)
    .attr('y', function( d, i ) {
      return 20 + i * 20;
    })
    .selectAll('tspan')
    .data( function( d, i ) { // d is from the first data
      return Array.from(d); // if needed, could return an array or object that includes the initial value, too.
    })
    .enter()
      .append('tspan')
      .attr('class', function( d, i ) {
        console.log(1, i, this.parentNode);
         //hijack the attr function to "grab" parent outside nested update pattern
        if (i == 0) {
          d3.select(this.parentNode).attr('class', 'strong');
        }
        //console.log( 'tspan class:', d, i );
        if ( ['a', 'e', 'i', 'o', 'u', 'y'].includes(d) ) {
          return 'vowel';
        }
      })
      .text( function( d, i, foo ) { // d is from the second data
        //console.log( 'tspan text:', d, i, /*foo*/ );
        return d;
      })    
    .exit();

This is also not a beautiful solution.

I think because once the .data() function is called on a selection, it is now longer a normal node list object, and the .exit() function is only designed to be used one layer deep.

I think the two solutions the OP outlined are the best, but I would love to be proven wrong!

Alex L
  • 4,168
  • 1
  • 9
  • 24
  • I should have mentioned I wanted to avoid using .html() too, because it feels like the library should be handling the creation of elements. Not sure there a pattern for my original question… played with more nesting… and ended up just setting a variable for the top parent and working with that; felt the most readable. – Fabien Snauwaert Feb 18 '20 at 08:51