简体   繁体   中英

How should I bind data to appended elements to a transformed D3.js group?

I am approaching the problem of appending a complex (two or more glyphs) symbol to some data. With D3.js, it seems that the right way to do so is appending the glyphs (in this example, circle s) to groups ( g ) joined to data:

datum <=> g <=> (circle, circle)

Both the groups and the appended glyphs have properties depending on data , so that for example g is translated by .start and the position of the second circle is given by .end for each datum.

In order to achieve this, I wrote the following code (see the notebook ), which however does not work as expected

function updatea (){
    a[0].start += 10*Math.sin(t);
    a[0].end += 10*Math.cos(t);
    console.log(a[0].end - a[0].start);
    t += 0.1;
    
    var miao = svg.selectAll('g.aaa').data(a).join('g')
        .classed('aaa',true)
        .attr('transform',(d, i)=>('translate('+d.start+','+(i+1)*50+')'));
    miao.append('circle').attr('r', 10).attr('fill','red');
    miao.append('circle').attr('r', 10).attr('cx', d=>d.end).attr('fill','red');
  }

The expected result would be as follows: two circles oscillate around their initial position, with a phase of period/4 between them. Instead, the second circle (to which I assigned an attribute cx , in order to give the position relative to the first one) is not refreshed, but instead all of its positions are drawn one after the other, oscillating with the translation in the attribute "transform".

翻译而不更新


I think that the problem is appending circles every time I update data; but how should I then append them? I tried something like this, following https://bost.ocks.org/mike/nest/ :

var groups = svg.selectAll('g').data(a).enter().append('g').attr('transform',(d, i)=>('translate('+d.start+','+(i+1)*50+')'));
  var circle_start = groups.selectAll('circle').data((d)=>{return d.start;}).enter().append('circle').attr('cx', d=>d).attr('cy', d=>100).attr('r', 10);
  var circle_end = groups.selectAll('circle').data((d)=>{return d.end;}).enter().append('circle').attr('cx', d=>d).attr('cy', d=>100).attr('r', 10);

but it gives no output. Doing a bit of debug, for example assigning another dataset to one of the two circles, apparently the problem lies in .data(d)=>{return d.end;}) .

Problem

On the pragmatic side, your update function doesn't work as expected because each update you append two new circles to each g entered or updated with selectAll().join() :

function updatea (){
    // join g's
    var miao = svg.selectAll('g.aaa').data(a).join('g')
        .classed('aaa',true)
        .attr('transform',(d, i)=>('translate('+d.start+','+(i+1)*50+')'));

    // append two circles to each g entered or updated:
    miao.append('circle').attr('r', 10).attr('fill','red');
    miao.append('circle').attr('r', 10).attr('cx', d=>d.end).attr('fill','red');
  }

If you inspect the page you'll see two new circles appended each update. You're never updating the circles that are already on the page, just the translate on the g .

On the more theoretical side, you are unclear if your approach is most appropriate for binding data to complex symbols.

Solution

In proposing a solution let's consider the theoretical side first.

D3 was designed with data binding in mind. Normally in D3 you want to bind one datum per element (or one datum per element with a single child in each level of nesting). Where data is grouped and each datum is represented with multiple children, we would often see a second, nested, selectAll().data().join() statement.

However, if your visualization uses a symbol that is always comprised of the same set of child elements, then we don't need to do a nested join. In fact we do not need to in order to stay true to the data binding philosophy in D3: we'll bind one datum to one symbol (symbol in the data visualization sense).

This is the approach I'll propose here.

Rationale

This approach has advantages depending on situation, for example, there may be cases where the symbol's elements share parts of the datum (as in your case where d.start and d.end are both used to set the position of one of the sub-components) - splitting the datum into a new data array would be unnecessarily cumbersome. Changes in the symbol's representation/behavior/etc may require different parts of the datum as well, in which case it doesn't make sense to split the parent datum up.

Also, another reason why the proposed approach is attractive is that if you break the datum into smaller sub-components by using a nested selection:

 svg.selectAll("g").data(data).enter().append("g")
    .selectAll("circle").data(function(d) { return [d.start,d.end]; })
    ...

Or by flattening your array:

 svg.selectAll("g").data([data[0].start,data[0].end,data[1].start,...])
     ...

It isn't as clear what child datum corresponds to what property when entering/updating your elements or what even what child datum corresponds to what parent datum. But also, say you dislike the symbol and now want a circle and rect, or two circles and a rect, then you need to substantially adjust the above approaches (perhaps by creating a fancy enter function that returns different types of shapes depending on index or on some identifier that tells you what symbol sub-component the datum corresponds to).

I believe attempting to create one unique datum per element is not ideal in this case, which is why I'm advocating for one datum per symbol.

Implementation

So, let's do one datum per symbol (where the symbols have child elements). This should be fairly easy to implement, I'll go over a simple method to do this here:

We can create the symbol in the join's enter function, and update it in the update function:

  function updatea (){
    a[0].start += 10*Math.sin(t);
    a[0].end += 10*Math.cos(t);
    t += 0.1;
    
    var miao = svg.selectAll('g').data(a).join(
      enter => {
         // create the parent element to hold the symbol
         let entered = enter.append('g')
            .attr('transform', (d,i) =>'translate('+d.start+','+(i+1)*50+')')
            .attr('class','symbol');
         // append the sub-components of the symbol         
         entered.append('circle').attr('r', 10).attr('fill','red');   
         entered.append('circle').attr('class','end').attr('r', 15).attr('fill','yellow').attr('cx',d=>d.end);
        
      },
      update => {
        // update overall positioning
        update.attr('transform', (d,i) =>'translate('+d.start+','+(i+1)*50+')')
        // update the sub-components
        update.select('.end').attr('cx',d=>d.end);
        return update
      },
      exit => exit.remove()
    )

First, it's important to note, even though you've likely noticed, the parent datum is passed to child elements when using selection.append() .

In the enter function passed to selection.join() We enter the g , style it as appropriate. Then we add the symbol sub-components and set their initial properties.

In the update function we update the overall position of the symbol and then the sub components.

Nothing occurs outside the join method in this case.

I cannot fork your observable without creating another account somewhere, so I'll just make a snippet of your example:

 const svg = d3.select("body").append("svg") .attr("width", 600) .attr("height", 150); var a = [{'start': 100, 'end': 200},{'start':100, 'end':200}]; var t = 0; function updatea (){ a[0].start += 5*Math.sin(t); a[0].end += 5*Math.cos(t); a[1].start += 5*Math.cos(t); a[1].end += 5*Math.sin(t); t += 0.1; var miao = svg.selectAll('g').data(a).join( enter => { // create the parent element to hold the symbol let entered = enter.append('g') .attr('transform', (d,i) =>'translate('+d.start+','+(i+1)*50+')') .attr('class','symbol'); // append the sub-components of the symbol entered.append('circle').attr('r', 10).attr('fill','red'); entered.append('circle').attr('class','end').attr('r', 15).attr('fill','yellow').attr('cx',d=>d.end); }, update => { // update overall positioning update.attr('transform', (d,i) =>'translate('+d.start+','+(i+1)*50+')') // update the sub-components update.select('.end').attr('cx',d=>d.end); return update }, exit => exit.remove() ) } updatea(); setInterval(updatea, 100)
 <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.0.0/d3.min.js"></script>

The idea is that since we have two circles, have two data with them. (Ignore what's in them for now:)

  var a = [{...},
           {...}];

Let's create a group:

  var group = svg.append("g")

Then, d3 will "join" the data to DOM nodes. For each data, d3 creates a DOM node. In this case, since we're join() ing to circles, d3 will create a circle for each data. See this page for more details.

group
  .selectAll('circle')
  .data(a)
  .join('circle')
  .attr('r', 10)
  .attr('fill','red')
  .attr('transform', (d, i) => ('translate('+d.start+','+(i+1)*50+')'));

As for the actual logic, there's a couple things I changed. Each circle now stores its own t and nothing else:

  var a = [{t: 0},
           {t: Math.PI/2}];

Then, the start and end attributes are set in order to have a representation independent of the current object's state. This allows us to have circles which have different t phases:

a.forEach((d, i) => {
  d.start = 200 + 100*Math.sin(d.t);
  d.end = 200 + 100*Math.cos(d.t);
  d.t += 0.1;
})

Breaking it down:

(initial  (range)
position) 
200     + 100*Math.cos(d.t);

So it starts at position 200 and can either go to +100 or -100: the effective range is [100, 300].

You notice we do a lot of number crunching here. Basically, we're converting one domain of numbers (-1, 1) to a range (100, 300). This is a common use case for a scale , which can convert any domain to any range.

Observable notebook

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM