javascriptd3.jsfilesaver.js

How to download data based on current selection only?


I have a simple chart I built with d3.js. There's a dropdown to filter between different views of the data, and want the user to be able to download the associated data based on their selection.

Using fileSaver.js, I'm able to make the download happen. However, after switching from the default selection (apples) to something else (like pears), multiple CSVs are downloaded. Is it possible to get the data only from the current selection?

var margin = { top: 50, right: 20, bottom: 30, left: 60 },
      width = 1000 - margin.left - margin.right,
      height = 550 - margin.top - margin.bottom;

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

    var xAxis = d3.axisBottom(x)
      .ticks(5)
    const parseTime = d3.utcParse("%Y");

    var yAxis = d3.axisLeft(y).ticks(5);

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

    var color = d3.scaleOrdinal()
      .range(['royalblue', 'green', 'pink']);

    var svg = d3.select("#chart")
      .append("svg")
      .attr("width", width + margin.left + margin.right)
      .attr("height", height + margin.top + margin.bottom)
      .append("g")
      .attr("transform",
        "translate(" + margin.left + "," + margin.top + ")");

    d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/fruit.csv").then(function (csv) {

      csv.forEach(function (d) {
        d.value = +d.value;
        d.year = parseTime(d.year);
      });

      x.domain(d3.extent(csv, function (d) { return d.date; }));

      initData = csv.filter(function (d) { return d.produce == 'apples' })
      updateGraph(initData)

      d3.select('#inds')
        .on("change", function () {
          var sect = document.getElementById("inds");
          var section = sect.options[sect.selectedIndex].value;

          data = csv.filter(function (d) { return d.produce == section })

          data.forEach(function (d) {
            d.value = +d.value;
            d.active = true;
          });
          updateGraph(data);

        });

      data = csv.filter(function (d) { return d.produce == 'apples' })
      updateGraph(data);
    });

    function updateGraph(data) {

      x.domain(d3.extent(data, function (d) { return d.year; }));
      y.domain([d3.min(data, function (d) { return d.value; }), d3.max(data, function (d) { return d.value; })]);

      let dataNest = d3.group(data, d => d.state)

      let dataNestArray = Array.from(dataNest.keys());

      var state = svg.selectAll(".line")
        .data(dataNest, function (d) { return d[0] });

      state.enter().append("path")
        .attr("class", "line");

      state.transition()
        .style("stroke", "#333")
        .attr("d", d => {
          d.line = this;
          return line(d[1]);
        })
        .style("stroke", d => color(d[0]))

      state.exit().remove();

      var legend = d3.select("#legend")
        .selectAll("text")
        .data(dataNest, function (d) { return d.key });

      svg.selectAll(".axis").remove();

      svg.append("g")
        .attr("class", "x axis")
        .attr("transform", "translate(0," + height + ")")
        .call(xAxis);

      svg.append("g")
        .attr("class", "y axis")
        .call(yAxis);

      downloadData('download', data, 'data.csv')

    };

    function downloadData(buttonID, data, filename) {

      let a = document.getElementById(buttonID);
      a.addEventListener('click', function (e) {

        let blob = new Blob([d3.csvFormat(data)],
          { type: "text/csv;charset=utf-8" });
        saveAs(blob, filename);
      })
    }
body {
      font: 12px Arial;
    }

    path {
      stroke: #ccc;
      stroke-width: 2;
      fill: none;
    }

    .axis path,
    .axis line {
      fill: none;
      stroke: grey;
      stroke-width: 1;
      shape-rendering: crispEdges;
    }

    #legendContainer {
      position: absolute;
      top: 60px;
      left: 10px;
      overflow: auto;
      height: 490px;
      width: 110px;
    }

    #legend {
      width: 90px;
      height: 200px;
    }

    .legend {
      font-size: 12px;
      font-weight: normal;
      text-anchor: left;
    }

    .legendcheckbox {
      cursor: pointer;
    }

    input {
      border-radius: 5px;
      padding: 5px 10px;
      background: #999;
      border: 0;
      color: #fff;
    }

    #inds {
      position: absolute;
      top: 10px;
      left: 10px;
    }

    #download {
      margin-top: 30px;
      margin-left: 30px;
      text-decoration: underline;
      color: darkred;
      cursor: pointer;
    }
<script src="https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<select id="inds">
    <option value="apples" selected="selected">apples</option>
    <option value="pears">pears</option>
    <option value="tomatoes">tomatoes</option>
  </select>
  <div id="chart"></div>
  <div id="download">Download Data</div>


Solution

  • It's doing exactly what you've told it to do. The change listener added to the select calls updateGraph which calls downloadData, which calls download data, which adds an event listener to download the data for the currently selected element from the list.

    The important part there is that downloadData is adding event listeners, but never removes any. So, each new selection is added on and they just pile up.

    To remove listeners you could use removeEventListener, but it needs to use the same arguments that addEventListener got which adds complexity.

    To make it more friendly, it could use an attribute on the download button (or some other element) to store the data and filename, and only add an event listener to that element once. Then updateGraph just updates the attribute data.

    This would not be great for larger data, but in that case a class/object could be used to encapsulate the functionality so it can store the data in JS without needing HTML attributes.

    var margin = { top: 50, right: 20, bottom: 30, left: 60 },
          width = 1000 - margin.left - margin.right,
          height = 550 - margin.top - margin.bottom;
    
        var x = d3.scaleTime().range([0, width]);
        var y = d3.scaleLinear().range([height, 0]);
    
        var xAxis = d3.axisBottom(x)
          .ticks(5)
        const parseTime = d3.utcParse("%Y");
    
        var yAxis = d3.axisLeft(y).ticks(5);
    
        var line = d3.line()
          .x(function (d) { return x(d.year); })
          .y(function (d) { return y(d.value); })
          .curve(d3.curveBasis);
    
        var color = d3.scaleOrdinal()
          .range(['royalblue', 'green', 'pink']);
    
        var svg = d3.select("#chart")
          .append("svg")
          .attr("width", width + margin.left + margin.right)
          .attr("height", height + margin.top + margin.bottom)
          .append("g")
          .attr("transform",
            "translate(" + margin.left + "," + margin.top + ")");
    
        d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/fruit.csv").then(function (csv) {
    
          csv.forEach(function (d) {
            d.value = +d.value;
            d.year = parseTime(d.year);
          });
    
          x.domain(d3.extent(csv, function (d) { return d.date; }));
    
          initData = csv.filter(function (d) { return d.produce == 'apples' })
          updateGraph(initData)
    
          d3.select('#inds')
            .on("change", function () {
              var sect = document.getElementById("inds");
              var section = sect.options[sect.selectedIndex].value;
    
              data = csv.filter(function (d) { return d.produce == section })
    
              data.forEach(function (d) {
                d.value = +d.value;
                d.active = true;
              });
              updateGraph(data);
    
            });
    
          data = csv.filter(function (d) { return d.produce == 'apples' })
          updateGraph(data);
        });
    
        function updateGraph(data) {
    
          x.domain(d3.extent(data, function (d) { return d.year; }));
          y.domain([d3.min(data, function (d) { return d.value; }), d3.max(data, function (d) { return d.value; })]);
    
          let dataNest = d3.group(data, d => d.state)
    
          let dataNestArray = Array.from(dataNest.keys());
    
          var state = svg.selectAll(".line")
            .data(dataNest, function (d) { return d[0] });
    
          state.enter().append("path")
            .attr("class", "line");
    
          state.transition()
            .style("stroke", "#333")
            .attr("d", d => {
              d.line = this;
              return line(d[1]);
            })
            .style("stroke", d => color(d[0]))
    
          state.exit().remove();
    
          var legend = d3.select("#legend")
            .selectAll("text")
            .data(dataNest, function (d) { return d.key });
    
          svg.selectAll(".axis").remove();
    
          svg.append("g")
            .attr("class", "x axis")
            .attr("transform", "translate(0," + height + ")")
            .call(xAxis);
    
          svg.append("g")
            .attr("class", "y axis")
            .call(yAxis);
    
          downloadData(data, 'data.csv')
    
        };
    
        function doDownload () {
          const data = JSON.parse(document.getAttribute('data-download-data'))
          const filename = document.getAttribute('data-download-filename')
    
          let blob = new Blob([d3.csvFormat(data)],
            { type: "text/csv;charset=utf-8" });
          saveAs(blob, filename);
        }
    
        function downloadData(data, filename) {
          const downloadBtn = document.getElementById('download');
          downloadBtn.setAttribute('data-download-data', JSON.stringify(data));
          downloadBtn.setAttribute('data-download-filename', filename);
        }
    
        document.getElementById('download').addEventListener('click', doDownload);
    body {
          font: 12px Arial;
        }
    
        path {
          stroke: #ccc;
          stroke-width: 2;
          fill: none;
        }
    
        .axis path,
        .axis line {
          fill: none;
          stroke: grey;
          stroke-width: 1;
          shape-rendering: crispEdges;
        }
    
        #legendContainer {
          position: absolute;
          top: 60px;
          left: 10px;
          overflow: auto;
          height: 490px;
          width: 110px;
        }
    
        #legend {
          width: 90px;
          height: 200px;
        }
    
        .legend {
          font-size: 12px;
          font-weight: normal;
          text-anchor: left;
        }
    
        .legendcheckbox {
          cursor: pointer;
        }
    
        input {
          border-radius: 5px;
          padding: 5px 10px;
          background: #999;
          border: 0;
          color: #fff;
        }
    
        #inds {
          position: absolute;
          top: 10px;
          left: 10px;
        }
    
        #download {
          margin-top: 30px;
          margin-left: 30px;
          text-decoration: underline;
          color: darkred;
          cursor: pointer;
        }
    <script src="https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
    <select id="inds">
        <option value="apples" selected="selected">apples</option>
        <option value="pears">pears</option>
        <option value="tomatoes">tomatoes</option>
      </select>
      <div id="chart"></div>
      <div id="download">Download Data</div>