d3.jslegendclickable

How can I use the d3.join method to create a clickable/interactive legend?


I am trying to create a clickable legend functionality in D3 version 7, using the .join() method.

My legend buttons are working and transmitting information correctly. However, there is something wrong with the update function that I can't quite put my finger on and it's breaking my heart. Here is how I'm going about it.

The Method

I have a simple dataset of three elements. From this I have created a set of categories. I use this set of categories to filter the data.

The graph is displayed in its entirety at the start. Then, when a legend category is clicked, the system checks if that category is on. If it's on, it's removed from the set of categories, and if it's off, it's added to the list of categories.

The Issue

I'm using console.table() to if see the categories and data are being filtered correctly, and they are. But the graph itself is not updating correctly.

If I click "Alice", the Carol datapoint is removed, Bob takes Carol's color and Alice takes Bob's, even though the data and the categories are correctly filtered.

If I click "Bob", the Carol datapoint is removed, Bob takes Carol's color and Alice remains as it should, even though the data and the categories are correctly filtered.

Everything works fine when I click "Carol", so it's reasonable to suppose that this only works because the Carol datapoint is the last element in the data array.

Does anybody know how I can fix this? I am very close, I know, but I can't close it out. An explanation of what's happening would be marvellous, but I'll settle for just getting the thing to work.

index.html

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <script src="http://d3js.org/d3.v7.min.js" defer></script>
        <script type="text/javascript" src="script.js" defer></script>
        <link rel="stylesheet" href="style.css" />
        <title>Joins</title>
    </head>
    <body>
        <div id="menu">
            <div id="Alice">Alice</div>
            <div id="Bob">Bob</div>
            <div id="Carol">Carol</div>
        </div>
        <div id="canvas"></div>
    </body>
</html>

style.css

body {
    font-family: sans-serif;
}

#menu div {
    display: inline-block;
    padding: 5px 10px;
    margin-right: 10px;
    color: white;
    border-radius: 5px;
    transition: 0.3s background-color;
    cursor: pointer;
    user-select: none;
}

.datapoint{
    font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
    font-size: 24px;
    font-weight: bold;
    text-anchor: middle;
}

#menu div#Alice {
    background-color: crimson;
}

#menu div#Bob {
    background-color: forestgreen;
}

#menu div#Carol {
    background-color: navy;
}

script.js

let data = [
  { Category: "Alice", Alpha: 300, Beta: 300 },
  { Category: "Bob", Alpha: 200, Beta: 200 },
  { Category: "Carol", Alpha: 100, Beta: 100 }];

let categories = new Set(data.map(d => d.Category));

function draw(data) {

  const width = 500;
  const height = 400;

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

  const plot_height = height - margin.top - margin.bottom;
  const plot_width = width - margin.left - margin.right;

  var canvas = d3
    .select("#canvas")
    .append("svg")
    .style("background", "#f0ffff")
    .attr("height", height)
    .attr("width", width);

  var plot = canvas
    .append("g")
    .attr("transform", `translate(${margin.left},${margin.top})`);

  var scaleColor = d3
    .scaleOrdinal()
    .domain(["Alice", "Bob", "Carol"])
    .range(["crimson", "forestgreen", "navy"]);

  function update(data) {
    plot
      .selectAll("text").data(data)
      .join(
        (enter) => {

          return enter
            .append("text")
            .attr("x", (d) => { return d.Alpha; })
            .attr("y", (d) => { return d.Beta; })
            .text(d => { return `${d.Category}_${d.Alpha}_${d.Beta}` })
            .attr("class", "datapoint")
            .style("fill", "aliceblue");
        },
        (update) => {
          console.table(data); // are the data correctly filtered?
          console.table(categories); // are the categories correctly filtered?
          return update;
        },
        (exit) => { return exit.remove(); }
      )
      .style("fill", (d) => scaleColor(d.Category));
  }

  // operating the menu
  d3.select("#menu").on("click", function (e) {
    categories.has(e.target.id) ? categories.delete(e.target.id) : categories.add(e.target.id);

    d2 = data.filter(d => { return categories.has(d.Category) });

    update(d2);
  })

  update(data);

}

draw(data);

Solution

  • In your .data call you don't provide a key function. By default, d3 uses index which won't work since you change the contents of the array of data. So simple fix is:

    function update(data) {
      plot
        .selectAll("text").data(data, d => d.Category) //<-- key function
        .join(
        ...
    

    Working code:

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <script src="https://d3js.org/d3.v7.js"></script>
        <style>
          body {
            font-family: sans-serif;
          }
    
          #menu div {
            display: inline-block;
            padding: 5px 10px;
            margin-right: 10px;
            color: white;
            border-radius: 5px;
            transition: 0.3s background-color;
            cursor: pointer;
            user-select: none;
          }
    
          .datapoint {
            font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS',
              sans-serif;
            font-size: 24px;
            font-weight: bold;
            text-anchor: middle;
          }
    
          #menu div#Alice {
            background-color: crimson;
          }
    
          #menu div#Bob {
            background-color: forestgreen;
          }
    
          #menu div#Carol {
            background-color: navy;
          }
        </style>
        <title>Joins</title>
      </head>
      <body>
        <div id="menu">
          <div id="Alice">Alice</div>
          <div id="Bob">Bob</div>
          <div id="Carol">Carol</div>
        </div>
        <div id="canvas"></div>
        <script>
          let data = [
            { Category: 'Alice', Alpha: 300, Beta: 300 },
            { Category: 'Bob', Alpha: 200, Beta: 200 },
            { Category: 'Carol', Alpha: 100, Beta: 100 },
          ];
    
          let categories = new Set(data.map((d) => d.Category));
    
          function draw(data) {
            const width = 500;
            const height = 400;
    
            const margin = { top: 20, bottom: 50, left: 50, right: 20 };
    
            const plot_height = height - margin.top - margin.bottom;
            const plot_width = width - margin.left - margin.right;
    
            var canvas = d3
              .select('#canvas')
              .append('svg')
              .style('background', '#f0ffff')
              .attr('height', height)
              .attr('width', width);
    
            var plot = canvas
              .append('g')
              .attr('transform', `translate(${margin.left},${margin.top})`);
    
            var scaleColor = d3
              .scaleOrdinal()
              .domain(['Alice', 'Bob', 'Carol'])
              .range(['crimson', 'forestgreen', 'navy']);
    
            function update(data) {
    
              plot
                .selectAll('text')
                .data(data, d => d.Category)
                .join(
                  (enter) => {
                    return enter
                      .append('text')
                      .attr('x', (d) => {
                        return d.Alpha;
                      })
                      .attr('y', (d) => {
                        return d.Beta;
                      })
                      .text((d) => {
                        return `${d.Category}_${d.Alpha}_${d.Beta}`;
                      })
                      .attr('class', 'datapoint')
                      .style('fill', 'aliceblue');
                  },
                  (update) => {
                    //console.table(data); // are the data correctly filtered?
                    //console.table(categories); // are the categories correctly filtered?
                    return update;
                  },
                  (exit) => {
                    return exit.remove();
                  }
                )
                .style('fill', (d) => scaleColor(d.Category));
            }
    
            // operating the menu
            d3.select('#menu').on('click', function (e) {
              categories.has(e.target.id)
                ? categories.delete(e.target.id)
                : categories.add(e.target.id);
    
              d2 = data.filter((d) => {
                return categories.has(d.Category);
              });
    
              update(d2);
            });
    
            update(data);
          }
    
          draw(data);
        </script>
      </body>
    </html>