0

so here goes. I've spent quite some time on this one - and am really tired - so hopefully something silly hasn't eluded me.

I am using a dataset to create a few lines in a chart. After that, using a legend I want to hide lines while also changing the dataset - so changing opacity won't cut it.

I followed the path of adding a key enabled on each object in my dataset and set it to false in order to be able to filter objects to hide. However, It doesn't work as expected since I cannot exit() the lines properly. Removing the lines completely and binding the dataset again gets the job done but messes with the mapping of the line colors to the legend items.

The problem lies somewhere in the redraw() function.

Hopefully someone is able to sort out this nightmare!

https://jsfiddle.net/2en21Lqh/2/

I have created a fiddle

scooterlord
  • 15,124
  • 11
  • 49
  • 68

2 Answers2

1

I understand your feeling... I hope this answer will help you.

You have a few problems in your code, so I fixed the main ones putting comments along the way. Keep in mind there is still a lot that can be improved in my version, up to you to make it better.

I will update the answer with a few comments detailing my thoughts about how to tackle this piece of code and a link to a useful article or answer here on SO.

Meanwhile check this fiddle, and the code below

// .. code above is unchanged 
line = d3.line()
  .x(function(d) {
    return x(d.date);
  }).y(function(d) {
    return y(d.value);
  });

// notice I deleted the part where you created the groups and the path the first time 
// You don't need it anymore

var legendItem = d3.select(".legend")
  .selectAll("li")
  .data(dataGroup)
  .enter()
  .append("li")
  .on('click', function(d) {
    if (d.enabled) {
      d.enabled = false;
    } else {
      d.enabled = true;
    }
    redraw();
  });

legendItem
  .append("span")
  .attr("class","color-square")
  .style("background", function(d, i) {
    // This is basically a hack for this demo 
    // You should find a better way to assign an index to a color
    var idx = +d.key.slice(-1);  
    return colorScale(idx - 1);
  });

legendItem
  .append("span")
  .text(function(d) {
    return (d.values[0].name)
  });

redraw = function() {

  y.domain([
    d3.min(dataGroup, function(c) {
      return d3.min(c.values, function(d) {
        return d.value;
      });
    }),
    // In the demo I don't rescale after the datasets change, so no need to filter
    // by enabled sets. If you want the max to be calculated only for enabled items
    // you will also have to redraw the axis, otherwise you will display incorrect data.
    d3.max(dataGroup, function(c) { 
      return d3.max(c.values, function(d) {
        return d.value;
      });
    })
  ]);

  // Notice that i filter the data passed to the .data() function,
  // not after it.
  lines = svg.selectAll("g.d3-group")
    .data(dataGroup.filter(d => d.enabled))

  // This is the Enter + update selection
  var linesEnter = lines
    .enter()
    .append("g")
    .merge(lines)
    .attr("class", "d3-group")

  // We want the data to go from the <g> to its child, the <path> element
  // To do so, we need to rebind the data.
  var pathJoin = linesEnter
    .selectAll('path')
    .data(d => [d]) // <= this is very important

  // Enter the path and update it
  var pathEnter = pathJoin
    .enter()
    .append("path")
    .attr("class", "d3-line")
    .merge(pathJoin)    
    .attr('d', function(d) {
      return line(d.values);
    })
    // Notice how "i" is not used. If you log it you will see it is always 0
    // as it is always the first child of its parent, the <g class="group">
    .style("stroke", function(d, i) {
      var idx = +d.key.slice(-1);
      return colorScale(idx - 1);
    });


    lines.exit().remove(); 

}

redraw();

If you follow the enter, update, exit pattern you don't need to have two different function to draw and update a chart. The action will be the same, so this means less code duplication. Hence, I removed the first part where you were drawing them at page load

var myline = svg.selectAll(".d3-group")...

An important thing to get right is piping the data from parent (<g class="d3-group">) to child (<path>). This is happening here:

linesEnter
    .selectAll('path')
    .data(d => [d]) // <= here

This way your paths will always be drawn by d3 according to the data that you pass to their respective parents, and will always be in sync.

Also, the reason why the mapping legend/lines gets messed up is because of how you are assigning the colors,i.e. by using indexes.

You will always have 3 labels in the legend, but a variable number of lines in the SVG (1, 2 or 3), so you can't rely on indexes to assign colors. I used a method that is far from perfect but gets the job done. You will want to find a more scalable and reliable one.

Let me know if you need any clarification.

A few links that might help:

Community
  • 1
  • 1
Aurelio
  • 24,702
  • 9
  • 60
  • 63
  • Thank you for your swift answer. I will check in detail tomorrow. However, There is need for the axes to be recalculated on redraw - otherwise I would just hide the lines using opacity. Also, thanks for the optimization - I wanted to take one thing at a time before I consolidated in a function. Your code seems to be working though - except the axes of course. – scooterlord Mar 21 '17 at 19:48
  • If it's not too much effort for you, I would really appreciate if you provided an update to your code to accommodate axes updates. Also, would you care to elaborate a bit more on the PathJoin part of your code? – scooterlord Mar 21 '17 at 19:59
1

Here's your code refactored with the following fixes:

  1. Condensed initial draw and redraw into single function.
  2. Properly handled enter, update, exit pattern in this single function.
  3. Introduced key function to fix data binding (keep it unique to the data).
  4. Use d.key for colors instead of index (to fix the wandering colors).
  5. Fixed some general code quality issues (missing var keywords, filtering on selection instead of data, etc...)

I did not "toggle" the lines using opacity, although that would work as well.

<!DOCTYPE html>
<html>

<head>
  <script data-require="d3@4.0.0" data-semver="4.0.0" src="https://d3js.org/d3.v4.min.js"></script>
  <script data-require="jquery@3.0.0" data-semver="3.0.0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.0.0/jquery.js"></script>
  <style>
    .line-chart {
      width: 800px;
      height: 200px;
    }
    
    .d3-axis {
      font-family: 'Arial', sans-serif;
      font-size: 10px;
    }
    
    .d3-line {
      fill: none;
      stroke-width: 2px;
    }
    
    .d3-axis path {
      fill: none;
      stroke: #e6e6e6;
      shape-rendering: crispEdges;
      opacity: 0; // remove axes
    }
    
    .d3-axis line {
      fill: none;
      stroke: #eee;
      shape-rendering: crispEdges;
    }
    
    ul li {
      display: inline-block;
      margin-left: 10px;
    }
    
    .color-square {
      display: block;
      float: left;
      margin-right: 3px;
      width: 20px;
      height: 20px;
      border: 1px solid #000;
    }
  </style>
</head>

<body>
  <div class="line-chart"></div>
  <ul class="legend"></ul>
  <script>
    var data = [{
      "name": "line1",
      "date": "2016-10-07T23:59:07Z",
      "value": 67
    }, {
      "name": "line1",
      "date": "2016-10-15T11:35:32Z",
      "value": 57
    }, {
      "name": "line1",
      "date": "2017-02-09T07:13:41Z",
      "value": 11
    }, {
      "name": "line1",
      "date": "2016-11-16T21:18:03Z",
      "value": 12
    }, {
      "name": "line1",
      "date": "2016-05-01T03:08:22Z",
      "value": 71
    }, {
      "name": "line1",
      "date": "2016-10-01T08:15:08Z",
      "value": 64
    }, {
      "name": "line1",
      "date": "2016-07-27T09:58:43Z",
      "value": 25
    }, {
      "name": "line1",
      "date": "2016-04-15T12:20:35Z",
      "value": 15
    }, {
      "name": "line1",
      "date": "2016-11-01T11:51:14Z",
      "value": 69
    }, {
      "name": "line1",
      "date": "2016-10-05T23:27:50Z",
      "value": 12
    }, {
      "name": "line1",
      "date": "2016-11-11T21:53:45Z",
      "value": 87
    }, {
      "name": "line1",
      "date": "2017-01-22T17:22:10Z",
      "value": 10
    }, {
      "name": "line1",
      "date": "2016-07-18T23:33:03Z",
      "value": 27
    }, {
      "name": "line1",
      "date": "2017-01-04T14:35:53Z",
      "value": 6
    }, {
      "name": "line1",
      "date": "2016-11-10T07:17:06Z",
      "value": 91
    }, {
      "name": "line1",
      "date": "2016-04-18T00:40:18Z",
      "value": 56
    }, {
      "name": "line1",
      "date": "2016-06-23T11:27:18Z",
      "value": 22
    }, {
      "name": "line1",
      "date": "2016-08-10T12:53:00Z",
      "value": 18
    }, {
      "name": "line1",
      "date": "2016-09-02T17:24:51Z",
      "value": 89
    }, {
      "name": "line1",
      "date": "2016-06-08T03:09:20Z",
      "value": 27
    }, {
      "name": "line1",
      "date": "2016-10-30T17:54:30Z",
      "value": 38
    }, {
      "name": "line1",
      "date": "2017-01-22T01:56:44Z",
      "value": 99
    }, {
      "name": "line1",
      "date": "2016-06-02T19:58:44Z",
      "value": 48
    }, {
      "name": "line1",
      "date": "2016-07-12T01:04:56Z",
      "value": 68
    }, {
      "name": "line1",
      "date": "2016-09-23T07:30:45Z",
      "value": 11
    }, {
      "name": "line1",
      "date": "2016-11-08T05:18:12Z",
      "value": 29
    }, {
      "name": "line1",
      "date": "2017-01-24T03:46:43Z",
      "value": 19
    }, {
      "name": "line2",
      "date": "2016-04-17T06:36:39Z",
      "value": 44
    }, {
      "name": "line2",
      "date": "2016-03-27T17:40:29Z",
      "value": 29
    }, {
      "name": "line2",
      "date": "2016-09-13T02:11:44Z",
      "value": 55
    }, {
      "name": "line2",
      "date": "2016-12-24T10:47:49Z",
      "value": 54
    }, {
      "name": "line2",
      "date": "2016-11-12T21:17:27Z",
      "value": 74
    }, {
      "name": "line2",
      "date": "2016-07-17T10:18:03Z",
      "value": 55
    }, {
      "name": "line2",
      "date": "2016-10-15T10:46:42Z",
      "value": 24
    }, {
      "name": "line2",
      "date": "2016-08-25T12:10:23Z",
      "value": 63
    }, {
      "name": "line2",
      "date": "2017-01-22T18:08:27Z",
      "value": 88
    }, {
      "name": "line2",
      "date": "2016-05-04T09:47:14Z",
      "value": 44
    }, {
      "name": "line2",
      "date": "2016-10-19T18:45:20Z",
      "value": 74
    }, {
      "name": "line2",
      "date": "2017-01-16T19:03:01Z",
      "value": 46
    }, {
      "name": "line2",
      "date": "2017-01-03T18:05:28Z",
      "value": 32
    }, {
      "name": "line2",
      "date": "2016-09-22T14:32:07Z",
      "value": 93
    }, {
      "name": "line2",
      "date": "2016-08-26T16:07:09Z",
      "value": 22
    }, {
      "name": "line2",
      "date": "2016-08-12T07:03:45Z",
      "value": 52
    }, {
      "name": "line2",
      "date": "2016-10-09T11:12:56Z",
      "value": 52
    }, {
      "name": "line2",
      "date": "2016-10-11T00:13:01Z",
      "value": 39
    }, {
      "name": "line2",
      "date": "2016-10-23T16:35:20Z",
      "value": 58
    }, {
      "name": "line2",
      "date": "2016-07-06T05:18:24Z",
      "value": 95
    }, {
      "name": "line2",
      "date": "2017-02-03T08:49:39Z",
      "value": 51
    }, {
      "name": "line2",
      "date": "2016-07-21T00:03:27Z",
      "value": 100
    }, {
      "name": "line2",
      "date": "2016-08-27T07:23:05Z",
      "value": 71
    }, {
      "name": "line3",
      "date": "2016-11-11T21:53:45Z",
      "value": 87
    }, {
      "name": "line3",
      "date": "2017-01-22T17:22:10Z",
      "value": 220
    }, {
      "name": "line3",
      "date": "2016-07-18T23:33:03Z",
      "value": 24
    }, {
      "name": "line3",
      "date": "2017-01-04T14:35:53Z",
      "value": 65
    }, {
      "name": "line3",
      "date": "2016-11-10T07:17:06Z",
      "value": 9
    }, {
      "name": "line3",
      "date": "2016-04-18T00:40:18Z",
      "value": 54
    }, {
      "name": "line3",
      "date": "2016-06-23T11:27:18Z",
      "value": 72
    }, {
      "name": "line3",
      "date": "2016-08-10T12:53:00Z",
      "value": 88
    }, {
      "name": "line3",
      "date": "2016-09-02T17:24:51Z",
      "value": 89
    }, {
      "name": "line3",
      "date": "2016-06-08T03:09:20Z",
      "value": 27
    }, {
      "name": "line3",
      "date": "2016-10-30T17:54:30Z",
      "value": 38
    }, {
      "name": "line3",
      "date": "2017-01-22T01:56:44Z",
      "value": 99
    }, {
      "name": "line3",
      "date": "2016-06-02T19:58:44Z",
      "value": 48
    }, {
      "name": "line3",
      "date": "2016-07-12T01:04:56Z",
      "value": 68
    }, {
      "name": "line3",
      "date": "2016-09-23T07:30:45Z",
      "value": 51
    }, {
      "name": "line3",
      "date": "2016-11-08T05:18:12Z",
      "value": 49
    }, {
      "name": "line3",
      "date": "2017-01-24T03:46:43Z",
      "value": 89
    }, {
      "name": "line3",
      "date": "2016-04-17T06:36:39Z",
      "value": 54
    }, {
      "name": "line3",
      "date": "2016-03-27T17:40:29Z",
      "value": 27
    }, {
      "name": "line3",
      "date": "2016-09-13T02:11:44Z",
      "value": 58
    }, {
      "name": "line3",
      "date": "2016-12-24T10:47:49Z",
      "value": 24
    }, {
      "name": "line3",
      "date": "2016-11-12T21:17:27Z",
      "value": 54
    }, {
      "name": "line3",
      "date": "2016-07-17T10:18:03Z",
      "value": 55
    }, {
      "name": "line3",
      "date": "2016-10-15T10:46:42Z",
      "value": 24
    }, {
      "name": "line3",
      "date": "2016-08-25T12:10:23Z",
      "value": 63
    }]

    margin = {
      top: 20,
      right: 20,
      bottom: 20,
      left: 30
    };

    var containerwidth = $('.line-chart').width(),
        containerheight = $('.line-chart').height();


    var width = containerwidth - margin.left - margin.right,
       height = containerheight - margin.top - margin.bottom;

    var parseTime = d3.timeParse("%Y-%m-%dT%H:%M:%SZ")

    data.forEach(function(d) {
      d.date = parseTime(d.date);
      d.value = +d.value;
    });

    function sortByDateAscending(a, b) {
      return a.date - b.date;
    }
    dataset = data.sort(sortByDateAscending);

    var dataGroup = d3.nest()
      .key(function(d) {
        return d.name;
      })
      .entries(data);

    dataGroup.forEach(function(d) {
      d.enabled = true;
    });
    
    var svg = d3.select('.line-chart')
      .append('svg')
      .attr('width', containerwidth)
      .attr('height', containerheight)
      .append('g')
      .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

    var x = d3.scaleTime().range([0, width]),
        y = d3.scaleLinear().range([height, 0]);

    var colorScale = d3.scaleOrdinal(d3.schemeCategory10);

    var xAxis = d3.axisBottom(x).ticks(20).tickSizeInner(2).tickSizeOuter(0);
    var yAxis = d3.axisLeft(y).ticks(5).tickSizeInner(-width).tickSizeOuter(0);
    var xAxisGroup = svg.append('g').attr('class', 'x d3-axis').attr('transform', 'translate(0,' + height + ')');
    var yAxisGroup = svg.append('g').attr('class', 'y d3-axis').attr('transform', 'translate(0,0)');

    var line = d3.line()
      .x(function(d) {
        return x(d.date);
      }).y(function(d) {
        return y(d.value);
      });

    var legendItem = d3.select(".legend")
      .selectAll("li")
      .data(dataGroup)
      .enter()
      .append("li")
      .on('click', function(d) {
        d.enabled = !d.enabled;
        redraw();
      });

    legendItem
      .append("span")
      .attr("class", "color-square")
      .style("background", function(d, i) {
        return colorScale(d.key);
      });

    legendItem
      .append("span")
      .text(function(d) {
        return (d.values[0].name)
      });

    redraw();

    function redraw() {

      var fData = dataGroup.filter(function(d) {
        return d.enabled;
      });
      
      y.domain([
        d3.min(fData, function(c) {
          return d3.min(c.values, function(d) {
            return d.value;
          });
        }),
        d3.max(fData, function(c) {
          return d3.max(c.values, function(d) {
            return d.value;
          });
        })
      ]);
      
      x.domain([
        d3.min(fData, function(c) {
          return d3.min(c.values, function(d) {
            return d.date;
          });
        }),
        d3.max(fData, function(c) {
          return d3.max(c.values, function(d) {
            return d.date;
          });
        })
      ]);
      
      xAxisGroup.call(xAxis);
      yAxisGroup.call(yAxis);

      // update selection
      lines = svg.selectAll(".d3-group")
        .data(fData, function(d) {
          return d.key
        });

      // exit the whole group
      lines
        .exit().remove();

      // enter selection
      linesEnter = lines
        .enter()
        .append("g")
        .attr("class", "d3-group");
        
      // add path on enter
      linesEnter.append("path")
        .attr("class", "d3-line");
        
      // add text on enter
      linesEnter.append("text")
        .attr("class", "my-label")
        .attr("x", function(d,i){
          return i * 100;
        })
        .attr("y", 10);
        
      // update + enter
      lines = lines.merge(linesEnter);
      
      // adjust label
      lines.select(".my-label")
        .text(function(d,i){
          return "hi Mom " + d.key;
        });

      // adjust path
      lines.select(".d3-line")
        .attr('d', function(d) {
          return line(d.values);
        })
        .style("stroke", function(d, i) {
          return colorScale(d.key);
        });

    }
  </script>
</body>

</html>
Mark
  • 106,305
  • 20
  • 172
  • 230
  • Thank you for your effort - will check in detail tomorrow. Everything seems to be working as it should :) Looks like I will learn from both your answer and from user Nobita's answer above! – scooterlord Mar 21 '17 at 19:50
  • From a quick glance, you removed the groups that hold the line paths - which I need for adding labels on the lines. Judging from the answer above this is where my mistake concerning the binding of data was. I would really appreciate it if you updated your answer, since you are using a very different syntax path from the answer above and would be interesting to see your way. – scooterlord Mar 21 '17 at 19:58
  • @scooterlord, yes I removed them since they weren't being used. I'll update with them back and some labels in there ;) – Mark Mar 21 '17 at 19:59
  • thank you! Looking forward to it. I still can't get my head around both answers :/ – scooterlord Mar 21 '17 at 20:06
  • @scooterlord, I put your `g` back with a rudimentary label in it. – Mark Mar 21 '17 at 21:17
  • Thanks for this. After a long hour I managed to implement this simplified example on my own code, however, I don't get a couple of things; concerning the key binding, is it used so that after an exit() and an enter() the object is bound to the same unique identifier? – scooterlord Mar 22 '17 at 09:13
  • @scooterlord, the key function allows `d3` to figure out which elements are entering or exiting. You have data bound to the DOM, each binding has a unique key. When re-bind the data and call `.enter()` or `.exit()`, d3 compares the old keys to the new keys, if the key is gone, it goes in the exit, if the key is new it goes in the enter. Without a key function, d3 does it by index. It's always good practice to have a key function that uniquely identifies each piece of your data. – Mark Mar 22 '17 at 13:02
  • Your answer helps actually - it now makes sense what the key does. :) Thanks! – scooterlord Mar 22 '17 at 14:42
  • if it's not too much trouble, can you elaborate as to what is the purpose of this merge? lines = lines.merge(linesEnter); Where would this be needed? – scooterlord Mar 24 '17 at 15:20
  • @scooterlord, it combines selections. In that line `lines` is the update selection, `lineEnter` is the enter selection, after the merge, `lines` is now the update + enter selection (it contains those elements being updated with those elements that are new). – Mark Mar 25 '17 at 13:18
  • wouldn't this play a role only if lines were added/removed inside the d3-group? Aren't we removing the d3-group altogether in this code? – scooterlord Mar 25 '17 at 13:35
  • 1
    @scooterlord, `lines` is a collection `g` elements. If data is removed the whole `g` is removed. If data is added a `g` is added and a `path` is appended to it. After that action, the `merge` happens, we now have update + enter `g`s (all the things now on the screen), we select the `path` children of those `g`s and update their `d` element to redraw them. – Mark Mar 25 '17 at 13:42
  • ..need some time to get around this.:/ currently experimenting into adding a second group that will hold circles. Looks like I am to anxious to get it right that can't think straight :| – scooterlord Mar 25 '17 at 13:52
  • Mark, so that you know that your efforts to help are bearing fruits, I managed to create what I was struggling since yesterday. That is add the circle focus points to each group. Now that I understand what's going on, it's just a matter of trial and error and lots and lots of practice! Thanks! – scooterlord Mar 25 '17 at 14:15