Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Combining Parent and Nested Data with d3.js

I have a data structure like this (assume that the data structure is non-negotiable):

data = {
    segments : [
        {x : 20, size : 10, colors : ['#ff0000','#00ff00']},
        {x : 40, size : 20, colors : ['#0000ff','#000000']}
    ]};

Using the d3.js javascript library, I'd like to draw four rectangles, one for each color in both colors arrays. Information from each entry in the segments array is used to draw the rectangles corresponding to each color in its color array. E.g., The red and green rectangles will have a width and height of 10. The resulting html should look like this:

<div id="container">
    <svg width="200" height="200">
        <g>
            <rect x="20" y="20" width="10" height="10" fill="#ff0000"></rect>
            <rect x="30" y="30" width="10" height="10" fill="#00ff00"></rect>
        </g>
        <g>
            <rect x="40" y="40" width="20" height="20" fill="#0000ff"></rect>
            <rect x="60" y="60" width="20" height="20" fill="#000000"></rect>
        </g>
    </svg>
</div>

I've come up with some code that accomplishes this, but I found the part about using data from two different levels of nesting in data to be confusing, and I feel that there might be a more idiomatic way to accomplish the same with d3.js. Here's the code (full example at http://jsbin.com/welcome/39650/edit):

function pos(d,i) { return d.x + (i * d.size); } // rect position
function size(d,i) { return d.size; }            // rect size
function f(d,i) { return d.color; }              // rect color

// add the top-level svg element and size it
vis = d3
    .select('#container')
    .append('svg')
    .attr('width',200)
    .attr('height',200);

// add the nested svg elements
var nested = vis
    .selectAll('g')
    .data(data.segments)
    .enter()
    .append('g');

// Add a rectangle for each color
nested
    .selectAll('rect')
    .data(function(d) {
        // **** ATTENTION ****
        // Is there a more idiomatic, d3-ish way to approach this?
        var expanded = [];
        for(var i = 0; i < d.colors.length; i++) {
            expanded.push({
                color : d.colors[i],
                x     : d.x
                size  : d.size });
        }
        return expanded;
    })
    .enter()
    .append('rect')
    .attr('x',pos)
    .attr('y',pos)
    .attr('width',size)
    .attr('height',size)
    .attr('fill',f);

Is there a better and/or more idiomatic way to access data from two different levels of nesting in a data structure using d3.js?

Edit

Here's the solution I came up with, thanks to meetamit's answer for the closure idea, and using more idiomatic d3.js indentation thanks to nautat's answer:

$(function() {
  var
    vis = null,
    width = 200,
    height = 200,
    data = {
        segments : [
           {x : 20, y : 0, size : 10, colors : ['#ff0000','#00ff00']},
           {x : 40, y : 0, size : 20, colors : ['#0000ff','#000000']}
        ]
    };

    // set the color
    function f(d,i) {return d;}

    // set the position
    function pos(segment) {
      return function(d,i) {
        return segment.x + (i * segment.size);
      };
    }

    // set the size
    function size(segment) {
      return function() {
        return segment.size;
      };
    }

    // add the top-level svg element and size it
    vis = d3.select('#container').append('svg')
        .attr('width',width)
        .attr('height',height);

    // add the nested svg elements
    var nested = vis
        .selectAll('g')
          .data(data.segments)
        .enter().append('g');

    // Add a rectangle for each color.  Size of rectangles is determined
    // by the "parent" data object.
    nested
    .each(function(segment, i) {
      var 
          ps = pos(segment),
          sz = size(segment);

      var colors = d3.select(this)
        .selectAll('rect')
          .data(segment.colors)
        .enter().append('rect')
          .attr('x', ps)
          .attr('y',ps)
          .attr('width', sz)
          .attr('height',sz)
          .attr('fill', f);
  });

});

Here's the full working example: http://jsbin.com/welcome/42885/edit

like image 391
John Vinyard Avatar asked Oct 25 '12 20:10

John Vinyard


3 Answers

You can use closures

var nested = vis
  .selectAll('g')
  .data(data.segments);


nested.enter()
  .append('g')
  .each(function(segment, i) {
    var colors = d3.select(this)
      .selectAll('rect')
      .data(segment.colors);

    colors.enter()
      .append('rect')
      .attr('x', function(color, j) { return pos(segment, j); })
      // OR: .attr('x', function(color, j) { return segment.x + (j * segment.size); })
      .attr('width', function(color, j) { return size(segment); })
      .attr('fill', String);
  });
like image 174
meetamit Avatar answered Nov 02 '22 10:11

meetamit


You could do something like the following to restructure your data:

newdata = data.segments.map(function(s) {
  return s.colors.map(function(d) {
    var o = this; // clone 'this' in some manner, for example:
    o = ["x", "size"].reduce(function(obj, k) { return(obj[k] = o[k], obj); }, {});
    return (o.color = d, o); 
  }, s);
});

This will transform your input data into:

// newdata:
    [
      [
        {"size":10,"x":20,"color":"#ff0000"},
        {"size":10,"x":20,"color":"#00ff00"}],
      [
        {"size":20,"x":40,"color":"#0000ff"},
        {"size":20,"x":40,"color":"#000000"}
      ]
    ]

which then can be used in the standard nested data selection pattern:

var nested = vis.selectAll('g')
    .data(newdata)
  .enter().append('g');

nested.selectAll('rect')
    .data(function(d) { return d; })
  .enter().append('rect')
    .attr('x',pos)
    .attr('y',pos)
    .attr('width',size)
    .attr('height',size)
    .attr('fill',f);

BTW, if you'd like to be more d3-idiomatic, I would change the indentation style a bit for the chained methods. Mike proposed to use half indentation every time the selection changes. This helps to make it very clear what selection you are working on. For example in the last code; the variable nested refers to the enter() selection. See the 'selections' chapter in: http://bost.ocks.org/mike/d3/workshop/

like image 3
nautat Avatar answered Nov 02 '22 09:11

nautat


I would try to flatten the colors before you actually start creating the elements. If changes to the data occur I would then update this flattened data structure and redraw. The flattened data needs to be stored somewhere to make real d3 transitions possible.

Here is a longer example that worked for me. Yon can see it in action here.

Here is the code:

var data = {
    segments : [
        {x : 20, size : 10, colors : ['#ff0000','#00ff00']},
        {x : 40, size : 20, colors : ['#0000ff','#000000']}
    ]
};

function pos(d,i) { return d.x + (i * d.size); } // rect position
function size(d,i) { return d.size; }            // rect size
function f(d,i) { return d.color; }              // rect color

function flatten(data) {
    // converts the .colors to a ._colors list
    data.segments.forEach( function(s,i) {
        var list = s._colors = s._colors || [];
        s.colors.forEach( function(c,j) {
            var obj = list[j] = list[j] || {}
            obj.color = c
            obj.x = s.x
            obj.size = s.size
        });
    });
}

function changeRect(chain) {
    return chain
    .transition()
    .attr('x',pos)
    .attr('y',pos)
    .attr('width',size)
    .attr('height',size)
    .attr('fill',f)
    .style('fill-opacity', 0.5)
}

vis = d3
.select('#container')
.append('svg')
.attr('width',200)
.attr('height',200);

// add the top-level svg element and size it
function update(){

    flatten(data);

    // add the nested svg elements
    var all = vis.selectAll('g')
    .data(data.segments)

    all.enter().append('g');
    all.exit().remove();

    // Add a rectangle for each color
    var rect = all.selectAll('rect')
    .data(function (d) { return d._colors; }, function(d){return d.color;})

    changeRect( rect.enter().append('rect') )
    changeRect( rect )

    rect.exit().remove()
}

function changeLater(time) {
    setTimeout(function(){
        var ds = data.segments
        ds[0].x    = 10 + Math.random() * 100;
        ds[0].size = 10 + Math.random() * 100;
        ds[1].x    = 10 + Math.random() * 100;
        ds[1].size = 10 + Math.random() * 100;
        if(time == 500)  ds[0].colors.push("orange")
        if(time == 1000) ds[1].colors.push("purple")
        if(time == 1500) ds[1].colors.push("yellow")
        update()
    }, time)
}

update()
changeLater(500)
changeLater(1000)
changeLater(1500)

Important here is the flatten function which does the data conversion and stores/reuses the result as _colors property in the parent data element. Another important line is;

.data(function (d) { return d._colors; }, function(d){return d.color;})

which specifies where to get the data (first parameter) AND what the unique id for each data element is (second parameter). This helps identifying existing colors for transitions, etc.

like image 1
Juve Avatar answered Nov 02 '22 10:11

Juve