Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why d3 updates entire data

I have an svg element with data created this way:

var chart = d3.select("#my-div").append("svg");
var chartData = [];
chartData.push([{x: 1, y: 3}, {x: 2, y: 5}]);
chartData.push([{x: 1, y: 2}, {x: 2, y: 3}]);

          .domain([1, 5]);
var lineFunc = d3.svg.line()
    .x(function (d) {
        return xRange(d.x);
    })
    .y(function (d) {
        return yRange(d.y);
    })
    .interpolate('linear');

chart.append('g').classed('lines', true).selectAll('path').data(chartData).enter()
    .append('path')
    .attr('d', function(d) {
        return lineFunc(d);
    })
    .attr('stroke', 'black')
    .attr('stroke-width', 1)
    .attr('fill', 'none');

After that I am trying to update my data and update the chart:

chartData[1].push({x: 5, y: 5});
chart.selectAll('g.lines').selectAll('path').data(chartData)
    .attr('d', function(d) {
        console.log('updating:');
        console.log(d);
        return lineFunc(d);
    })
    .attr('stroke', 'black')
    .attr('stroke-width', 1)
    .attr('fill', 'none');

but it prints 'updating' twice (for both of the chartData elements), but I've changed only one (chartData[1]). How to prevent it to not update the ones that I didn't change? I will have many functions, so it will be ineffiecient to update all of them when only one has changed.

// EDIT to @mef's answer

I changed data to (I don't mind updating entire chartData[X] data, I just want to avoid updating entire chartData):

chartData.push({key: 'A', data: [{x: 1, y: 3}, {x: 2, y: 5}]});
chartData.push({key: 'B', data: [{x: 1, y: 2}, {x: 2, y: 3}]});

and then when adding data I've put .data(chartData, function(d) {return d.key}) and when updating I did the same, but it still updates both.

I tried also to put .data(chartData, function(d) {return 'A'}) or .data(chartData, function(d) {return 'B'}) when updating the data and it updates only one, but always the data with A key (whether this function returns A or B).

So the whole code looks like this:

var chart = d3.select("#my-div").append("svg");
var chartData = [];
chartData.push({key: 'A', data: [{x: 1, y: 3}, {x: 2, y: 5}]});
chartData.push({key: 'B', data: [{x: 1, y: 2}, {x: 2, y: 3}]});

var xRange = d3.scale.linear().range([50, 780]).domain([1, 5]);
var yRange = d3.scale.linear().range([380, 20]).domain([2, 9]);

var lineFunc = d3.svg.line()
    .x(function (d) {
        return xRange(d.x);
    })
    .y(function (d) {
        return yRange(d.y);
    })
    .interpolate('linear');

chart.append('g').classed('lines', true).selectAll('path')
    .data(chartData, function(d) {return d.key}).enter()
    .append('path')
    .attr('d', function(d) {
        return lineFunc(d.data);
    })
    .attr('stroke', 'black')
    .attr('stroke-width', 1)
    .attr('fill', 'none');

updating data

chartData[1].data.push({x: 5, y: 5});
chart.selectAll('g.lines').selectAll('path')
    .data(chartData, function(d) {return d.key})
    .attr('d', function(d) {
        console.log('updating:');
        console.log(d);
        return lineFunc(d.data);
    })
    .attr('stroke', 'black')
    .attr('stroke-width', 1)
    .attr('fill', 'none');
like image 931
tobi Avatar asked Sep 28 '22 18:09

tobi


2 Answers

OK, it can be done...

Option 1 - use key

Here is a lazy way to do it...

Strategy

  1. Make a key function that will detect changes in data.
    Do this by reading the node attribute string and comparing it with the attribute generator function result, called on the datum.
  2. Detect the phase of the d3 data binding process (key on nodes or key on data) and use different key for each:
    var k = Array.isArray(this) ? lineD(d, lineFunc) : d3.select(this).attr("d");

  3. Align the formatting of the two key values by writing and reading back from a dummy node during the "data key" phase. (that's the lazy part!)

  4. Keep separate references for the update, exit and enter selections to decouple their behaviour.

Code

var chart = d3.select("#my-div").append("svg")
      .attr("height", 600)
      .attr("width", 900);
var chartData = [];
chartData.push([{x: 1, y: 3}, {x: 2, y: 5}]);
chartData.push([{x: 1, y: 2}, {x: 2, y: 3}]);

var xRange = d3.scale.linear().range([50, 780]).domain([1, 5]);
var yRange = d3.scale.linear().range([380, 20]).domain([2, 9]);

var lineFunc = d3.svg.line()
    .x(function (d) {
      return xRange(d.x);
    })
    .y(function (d) {
      return yRange(d.y);
    })
    .interpolate('linear');

chart.append('g').classed('lines', true).selectAll('path')
    .data(chartData, key)
    .enter().append('path')
    .attr('d', function(d) {
      return lineFunc(d);
    })
    .attr('stroke', 'black')
    .attr('stroke-width', 1)
    .attr('fill', 'none');
//updating data

chartData[1].push({x: 5, y: 5});

var update = chart.selectAll('g.lines').selectAll('path')
      .data(chartData, key);
update.enter().append('path')
      .attr('d', function (d) {
        console.log('updating:');
        console.log(d);
        return lineFunc(d);
      })
      .attr('stroke', 'black')
      .attr('stroke-width', 1)
      .attr('fill', 'none');
update.exit().remove();

function key(d, i, j) {
  var k = Array.isArray(this) ? lineAttr(d, lineFunc, "d") : d3.select(this).attr("d");
  console.log((Array.isArray(this) ? "data\t" : "node\t") + k)
  return k;

  function lineAttr(d, lineFunct, attribute) {
    var l = d3.select("svg").selectAll("g")
      .append("path").style("display", "none")
      .attr(attribute, lineFunct(d))
    d = l.attr(attribute);
    l.remove();
    return d;
  }
}

Output

node    M50,328.57142857142856L232.5,225.71428571428572          
node    M50,380L232.5,328.57142857142856                         
data    M50,328.57142857142856L232.5,225.71428571428572          
data    M50,380L232.5,328.57142857142856L780,225.71428571428572

updating:                                                      
Array[3]0: Object1: Object2: Objectlength: 3__proto__: Array[0]

Option 2 - use filter

This is more efficient but only applies if you know that only the number of points on the lines will change and the number of lines is fixed.

Strategy

  1. Join the data without a key function and filter it by comparing the attribute string calculated from the bound data, to the current attribute string in the DOM element.
  2. As in option 1, use a dummy node as a lazy (and cross-browser) way to align the formatting of the node attribute and the calculated attribute text.

Code

//updating data

chartData[1].push({x: 5, y: 5});

chart.selectAll('g.lines').selectAll('path')
  .data(chartData)
  .filter(changed)
  .attr('d', function (d) {
    console.log('updating:');
    console.log(d);
    return lineFunc(d);
  })
  .attr('stroke', 'black')
  .attr('stroke-width', 1)
  .attr('fill', 'none');

function changed(d) {
  var s = d3.select(this);
  console.log("data\t" + lineAttr(s.datum(), lineFunc, "d"));
  console.log("node\t" + s.attr("d")); console.log("\n")
  return lineAttr(s.datum(), lineFunc, "d") != s.attr("d");

  function lineAttr(d, lineFunct, attribute) {
    var l = d3.select("svg").selectAll("g")
      .append("path").style("display", "none")
      .attr(attribute, lineFunct(d))
    d = l.attr(attribute);
    l.remove();
    return d;
  }
}

Output

data    M50,328.57142857142856L232.5,225.71428571428572
node    M50,328.57142857142856L232.5,225.71428571428572

data    M50,380L232.5,328.57142857142856L780,225.71428571428572
node    M50,380L232.5,328.57142857142856

updating:
Array[3]

Option 3 - best of both worlds

Strategy

  1. Use a standard update/enter/exit pattern.
  2. Filter the update selection to form a "changed" selection before operating on it.

Code

//updating data

alert("base");
chartData[1].push({ x: 5, y: 5 });
updateViz();
alert("change");
chartData.push([{x: 3, y: 1}, {x: 5, y: 2}])
updateViz();
alert("enter");
chartData.shift();
updateViz();
alert("exit");

function updateViz() {
  var update = chart.selectAll('g.lines').selectAll('path')
      .data(chartData),

      enter = update.enter()
        .append('path')
        .attr('d', function (d) {
          return lineFunc(d);
        })
        .attr('stroke', 'black')
        .attr('stroke-width', 1)
        .attr('fill', 'none'),

      changed = update.filter(changed)
        .attr('d', function (d) {
          console.log('updating:');
          console.log(d);
          return lineFunc(d);
        });

  update.exit().remove();

  function changed(d) {
    var s = d3.select(this);
    console.log("data\t" + lineAttr(s.datum(), lineFunc, "d"));
    console.log("node\t" + s.attr("d")); console.log("\n")
    return lineAttr(s.datum(), lineFunc, "d") != s.attr("d");

    function lineAttr(d, lineFunct, attribute) {
      var l = d3.select("svg").selectAll("g")
        .append("path").style("display", "none")
        .attr(attribute, lineFunct(d))
      d = l.attr(attribute);
      l.remove();
      return d;
    }
  }
}

Background

Read this

like image 196
Cool Blue Avatar answered Oct 07 '22 17:10

Cool Blue


You should use a key function, in order to allow to d3 to find out whether the records have changed, and match accordingly when you update the dataset.

At the moment, your data elements are javascript objects, and d3 does not check whether they have been changed compared to previous version (that would be tricky).

Ideally, you should find a unique identifier for your records, and include it in your dataset.

You would then replace .data(chartData)

by .data(chartData, function(d) { return d.id })

In case you have no property in your dataset you could use as unique record identifier, you still can do something like this:

.data(chartData, function(d) {
    return d.map(function(coord) {
        return coord.x + '-' + coord.y
    }).join('-')
}

The key here would be a concatenation of all coordinates values of your object.

like image 41
Mehdi Avatar answered Oct 07 '22 17:10

Mehdi