Mr. Polywhirl
Mr. Polywhirl

Reputation: 48600

Group D3 legend items

How can I group the circle and text for each legend item?

I am having an issue with enter/exit. I can create the groups (g), but I cannot append the text/circle elements.

li.selectAll("g")
  .data(items, ([key]) => key)
  .call((d) => d.enter().append("text")
    .attr("y", (d, i) => (i * 1.25) + "em")
    .attr("x", "1em")
    .text(([key]) => key))
  .call((d) => d.exit().remove())
  .call((d) => d.enter().append("circle")
    .attr("cy", (d, i) => (i * 1.25 - 0.25) + "em")
    .attr("cx", 0)
    .attr("r", "0.4em")
    .style("fill", ([key, value]) => value.color))

Here is the current rendered SVG markup:

<g class="legend-items">
  <text y="0em" x="1em">Amanda</text>
  <text y="1.25em" x="1em">Linda</text>
  <text y="2.5em" x="1em">Dorothy</text>
  <text y="3.75em" x="1em">Betty</text>
  <text y="5em" x="1em">Helen</text>
  <text y="6.25em" x="1em">Patricia</text>
  <text y="7.5em" x="1em">Jessica</text>
  <text y="8.75em" x="1em">Ashley</text>
  <text y="10em" x="1em">Deborah</text>
  <circle cy="-0.25em" cx="0" r="0.4em" style="fill: rgb(228, 26, 28);"></circle>
  <circle cy="1em" cx="0" r="0.4em" style="fill: rgb(247, 129, 191);"></circle>
  <circle cy="2.25em" cx="0" r="0.4em" style="fill: rgb(255, 127, 0);"></circle>
  <circle cy="3.5em" cx="0" r="0.4em" style="fill: rgb(77, 175, 74);"></circle>
  <circle cy="4.75em" cx="0" r="0.4em" style="fill: rgb(255, 255, 51);"></circle>
  <circle cy="6em" cx="0" r="0.4em" style="fill: rgb(153, 153, 153);"></circle>
  <circle cy="7.25em" cx="0" r="0.4em" style="fill: rgb(166, 86, 40);"></circle>
  <circle cy="8.5em" cx="0" r="0.4em" style="fill: rgb(55, 126, 184);"></circle>
  <circle cy="9.75em" cx="0" r="0.4em" style="fill: rgb(152, 78, 163);"></circle>
</g>

This is the desired markup:

<g class="legend-items">
  <g class="legend-item" data-key="Amanda">
    <text y="0em" x="1em">Amanda</text>
    <circle cy="-0.25em" cx="0" r="0.4em" style="fill: rgb(228, 26, 28);"></circle>
  </g>
  <g class="legend-item" data-key="Linda">
    <text y="1.25em" x="1em">Linda</text>
    <circle cy="1em" cx="0" r="0.4em" style="fill: rgb(247, 129, 191);"></circle>
  </g>
  <g class="legend-item" data-key="Dorothy">
    <text y="2.5em" x="1em">Dorothy</text>
    <circle cy="2.25em" cx="0" r="0.4em" style="fill: rgb(255, 127, 0);"></circle>
  </g>
  <g class="legend-item" data-key="Betty">
    <text y="3.75em" x="1em">Betty</text>
    <circle cy="3.5em" cx="0" r="0.4em" style="fill: rgb(77, 175, 74);"></circle>
  </g>
  <g class="legend-item" data-key="Helen">
    <text y="5em" x="1em">Helen</text>
    <circle cy="4.75em" cx="0" r="0.4em" style="fill: rgb(255, 255, 51);"></circle>
  </g>
  <g class="legend-item" data-key="Patricia">
    <text y="6.25em" x="1em">Patricia</text>
    <circle cy="6em" cx="0" r="0.4em" style="fill: rgb(153, 153, 153);"></circle>
  </g>
  <g class="legend-item" data-key="Jessica">
    <text y="7.5em" x="1em">Jessica</text>
    <circle cy="7.25em" cx="0" r="0.4em" style="fill: rgb(166, 86, 40);"></circle>
  </g>
  <g class="legend-item" data-key="Ashley">
    <text y="8.75em" x="1em">Ashley</text>
    <circle cy="8.5em" cx="0" r="0.4em" style="fill: rgb(55, 126, 184);"></circle>
  </g>
  <g class="legend-item" data-key="Deborah">
    <text y="10em" x="1em">Deborah</text>
    <circle cy="9.75em" cx="0" r="0.4em" style="fill: rgb(152, 78, 163);"></circle>
  </g>
</g>

This way I can add an event listener to .legend-item and use the data attribute key to cross-reference with the line-series.

Note: The legend code below is an updated version (6.2.0) of d3-legend. I tweaked the code slightly.


Example

Here is a working example of what I have so far.

(function() {
  d3.legend = function(g) {
    g.each(function() {
      var g = d3.select(this),
        items = {},
        svg = d3.select(g.property("nearestViewportElement")),
        legendPadding = g.attr("data-style-padding") || 5,
        lb = g.selectAll(".legend-box").data([true]),
        li = g.selectAll(".legend-items").data([true]);

      lb = lb.enter().append("rect").classed("legend-box", true).merge(lb);
      li = li.enter().append("g").classed("legend-items", true).merge(li);

      svg.selectAll("[data-legend]").each(function() {
        var self = d3.select(this);
        items[self.attr("data-legend")] = {
          pos: self.attr("data-legend-pos") || this.getBBox().y,
          color: self.attr("data-legend-color") ||
            self.style("stroke") ||
            self.style("fill")
        };
      });

      items = Object.entries(items)
        .sort(([keyA, valA], [keyB, valB]) => valA.pos - valB.pos);

      li.selectAll("text")
        .data(items, ([key]) => key)
        .call((d) => d.enter().append("text")
          .attr("y", (d, i) => (i * 1.25) + "em")
          .attr("x", "1em")
          .text(([key]) => key))
        .call((d) => d.exit().remove())

      li.selectAll("circle")
        .data(items, ([key]) => key)
        .call((d) => d.enter().append("circle")
          .attr("cy", (d, i) => (i * 1.25 - 0.25) + "em")
          .attr("cx", 0)
          .attr("r", "0.4em")
          .style("fill", ([key, value]) => value.color))

      // Reposition and resize the box
      var lbbox = li.node().getBBox();
      lb.attr("x", lbbox.x - legendPadding)
        .attr("y", lbbox.y - legendPadding)
        .attr("height", lbbox.height + 2 * legendPadding)
        .attr("width", lbbox.width + 2 * legendPadding);
    });

    return g;
  };
})();

const apiUrl = "https://raw.githubusercontent.com/holtzy/data_to_viz/master/Example_dataset/5_OneCatSevNumOrdered.csv";

const palette = [
  "#e41a1c",
  "#377eb8",
  "#4daf4a",
  "#984ea3",
  "#ff7f00",
  "#ffff33",
  "#a65628",
  "#f781bf",
  "#999999"
];

const chartConfig = {
  groupingKey: "name",
  xField: "year",
  yField: "n"
};

const mapperFn = ({ year, n, prop, ...rest }) => ({
  ...rest,
  year: parseInt(year, 10),
  n: parseFloat(n),
  prop: parseFloat(prop)
});

const { groupingKey, xField, yField } = chartConfig;

const margin = { top: 10, right: 120, bottom: 20, left: 60 },
  width = 400 - margin.left - margin.right,
  height = 200 - margin.top - margin.bottom;

d3.csv(apiUrl).then((jsonData) => {
  const data = jsonData.map(mapperFn);
  const ref = document.querySelector('.d3-chart > svg');
  const svgElement = d3.select(ref);

  // Empty the children
  svgElement.selectAll("*").remove();

  // Inner-SVG
  const svgInner = svgElement
    .append("g")
    .attr("transform", `translate(${margin.left}, ${margin.top})`);

  // Group the data: I want to draw one line per group
  const groupedData = [ ...d3.group(data, (d) => d[groupingKey]) ]
    .map(([key, values]) => ({ key, values }));
    
  const halfway = Math.floor(groupedData.length / 2);
  const dataLeft = groupedData.slice(0, halfway);
  const dataRight = groupedData.slice(halfway);

  // Add X axis --> it is a date format
  var xScale = d3
    .scaleLinear()
    .domain(d3.extent(data, (d) => d[xField]))
    .range([0, width]);

  svgInner
    .append("g")
    .attr("class", "d3-axis d3-axis-bottom")
    .attr("transform", `translate(0, ${height})`)
    .call(
      d3
      .axisBottom(xScale)
      .ticks(2)
      .tickFormat((year) => year.toString())
    );

  // Add Y axis
  var yScaleLeft = d3
    .scaleLinear()
    .domain([
      0,
      d3.max(
        dataLeft.flatMap((d) => d.values),
        (d) => d[yField]
      )
    ])
    .range([height, 0]);

  var yScaleRight = d3
    .scaleLinear()
    .domain([
      0,
      d3.max(
        dataRight.flatMap((d) => d.values),
        (d) => d[yField]
      )
    ])
    .range([height, 0]);

  svgInner
    .append("g")
    .attr("class", "d3-axis d3-axis-right")
    .call(d3.axisLeft(yScaleLeft));

  svgInner
    .append("g")
    .attr("class", "d3-axis d3-axis-right")
    .attr("transform", `translate(${width}, 0)`)
    .call(d3.axisRight(yScaleRight));

  // Color palette
  var res = groupedData.map((d) => d.key).sort(); // list of group names

  var color = d3.scaleOrdinal().domain(res).range(palette);

  svgInner
    .append("g")
    .attr("transform", `translate(${margin.left}, ${margin.top})`);

  // Draw the line
  svgInner
    .selectAll(".line")
    .data(dataLeft)
    .enter()
    .append("path")
    .attr("fill", "none")
    .attr("data-legend", (d) => d.key)
    .attr("stroke", (d) => color(d.key))
    .attr("stroke-width", 1.5)
    .attr("d", function(d) {
      return d3
        .line()
        .x((d) => xScale(d[xField]))
        .y((d) => yScaleLeft(+d[yField]))(d.values);
    });

  svgInner
    .selectAll(".line")
    .data(dataRight)
    .enter()
    .append("path")
    .attr("fill", "none")
    .attr("data-legend", (d) => d.key)
    .attr("stroke", (d) => color(d.key))
    .attr("stroke-width", 1.5)
    .attr("d", function(d) {
      return d3
        .line()
        .x((d) => xScale(d[xField]))
        .y((d) => yScaleRight(+d[yField]))(d.values);
    });

  let legend = svgInner
    .append("g")
    .attr("class", "legend")
    .attr("transform", "translate(280,20)")
    .style("font-size", "12px")
    .call(d3.legend);
});
:root {
  --chart-background: #272727;
  --chart-foreground: #d7d7d7;
}

.d3-chart {
  background: var(--chart-background);
}

.d3-chart .d3-axis line,
.d3-chart .d3-axis path {
  stroke: var(--chart-foreground);
}

.d3-chart .d3-axis text {
  fill: var(--chart-foreground);
}

.d3-chart .legend-box {
  fill: none;
}

.d3-chart .legend .legend-items text {
  fill: var(--chart-foreground);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js"></script>
<div class="d3-chart">
  <svg width="400px" height="200px" />
</div>

Upvotes: 1

Views: 468

Answers (1)

Ruben Helsloot
Ruben Helsloot

Reputation: 13129

The result of .enter() is a selection that has corresponding datum values, but does not have any nodes related to it. That is why we normally only merge at the very last moment, after having called .append("g").

g = li.selectAll("g")
  .data(items);

g
  .enter()
  .append("g")
  .merge(g);

Because you can't append g twice, you would need to append a g element, then a text element, and then return the g. This would look something like this:

li.selectAll("g")
  .data(items, ([key]) => key)
  .call((d) => {
    const g = d.enter()
      .append("g");

    g.append("text")
      .attr("y", (d, i) => (i * 1.25) + "em")
      .attr("x", "1em")
      .text(([key]) => key);

    g.append("circle")
      .attr("cy", (d, i) => (i * 1.25 - 0.25) + "em")
      .attr("cx", 0)
      .attr("r", "0.4em")
      .style("fill", ([key, value]) => value.color)

    return g;
  })
  .call((d) => d.exit().remove())

However, since you're using d3 v6, you can also use the new .join() functionality, where you can apply functions to your .enter(), .exit() and (implicit) update parts of the selection, and which returns the merged result:

li.selectAll("g")
  .data(items, ([key]) => key)
  .join(
    enter => {
      const g = enter.append("g");
      g.append("text")
        .attr("y", (d, i) => (i * 1.25) + "em")
        .attr("x", "1em")
        .text(([key]) => key);
      g.append("circle")
        .attr("cy", (d, i) => (i * 1.25 - 0.25) + "em")
        .attr("cx", 0)
        .attr("r", "0.4em")
        .style("fill", ([key, value]) => value.color);
      return g;
    },
    update => update,
    exit => exit.remove()
  );

That leads to the following snippet:

(function() {
  d3.legend = function(g) {
    g.each(function() {
      var g = d3.select(this),
        items = {},
        svg = d3.select(g.property("nearestViewportElement")),
        legendPadding = g.attr("data-style-padding") || 5,
        lb = g.selectAll(".legend-box").data([true]),
        li = g.selectAll(".legend-items").data([true]);

      lb = lb.enter().append("rect").classed("legend-box", true).merge(lb);
      li = li.enter().append("g").classed("legend-items", true).merge(li);

      svg.selectAll("[data-legend]").each(function() {
        var self = d3.select(this);
        items[self.attr("data-legend")] = {
          pos: self.attr("data-legend-pos") || this.getBBox().y,
          color: self.attr("data-legend-color") ||
            self.style("stroke") ||
            self.style("fill")
        };
      });

      items = Object.entries(items)
        .sort(([keyA, valA], [keyB, valB]) => valA.pos - valB.pos);

      li.selectAll("g")
        .data(items, ([key]) => key)
        .join(
          enter => {
            const g = enter.append("g");
            g.append("text")
              .attr("y", (d, i) => (i * 1.25) + "em")
              .attr("x", "1em")
              .text(([key]) => key);
            g.append("circle")
              .attr("cy", (d, i) => (i * 1.25 - 0.25) + "em")
              .attr("cx", 0)
              .attr("r", "0.4em")
              .style("fill", ([key, value]) => value.color);
            return g;
          },
          update => update,
          exit => exit.remove()
        );

      // Reposition and resize the box
      var lbbox = li.node().getBBox();
      lb.attr("x", lbbox.x - legendPadding)
        .attr("y", lbbox.y - legendPadding)
        .attr("height", lbbox.height + 2 * legendPadding)
        .attr("width", lbbox.width + 2 * legendPadding);
    });

    return g;
  };
})();

const apiUrl = "https://raw.githubusercontent.com/holtzy/data_to_viz/master/Example_dataset/5_OneCatSevNumOrdered.csv";

const palette = [
  "#e41a1c",
  "#377eb8",
  "#4daf4a",
  "#984ea3",
  "#ff7f00",
  "#ffff33",
  "#a65628",
  "#f781bf",
  "#999999"
];

const chartConfig = {
  groupingKey: "name",
  xField: "year",
  yField: "n"
};

const mapperFn = ({ year, n, prop, ...rest }) => ({
  ...rest,
  year: parseInt(year, 10),
  n: parseFloat(n),
  prop: parseFloat(prop)
});

const { groupingKey, xField, yField } = chartConfig;

const margin = { top: 10, right: 120, bottom: 20, left: 60 },
  width = 400 - margin.left - margin.right,
  height = 200 - margin.top - margin.bottom;

d3.csv(apiUrl).then((jsonData) => {
  const data = jsonData.map(mapperFn);
  const ref = document.querySelector('.d3-chart > svg');
  const svgElement = d3.select(ref);

  // Empty the children
  svgElement.selectAll("*").remove();

  // Inner-SVG
  const svgInner = svgElement
    .append("g")
    .attr("transform", `translate(${margin.left}, ${margin.top})`);

  // Group the data: I want to draw one line per group
  const groupedData = [ ...d3.group(data, (d) => d[groupingKey]) ]
    .map(([key, values]) => ({ key, values }));
    
  const halfway = Math.floor(groupedData.length / 2);
  const dataLeft = groupedData.slice(0, halfway);
  const dataRight = groupedData.slice(halfway);

  // Add X axis --> it is a date format
  var xScale = d3
    .scaleLinear()
    .domain(d3.extent(data, (d) => d[xField]))
    .range([0, width]);

  svgInner
    .append("g")
    .attr("class", "d3-axis d3-axis-bottom")
    .attr("transform", `translate(0, ${height})`)
    .call(
      d3
      .axisBottom(xScale)
      .ticks(2)
      .tickFormat((year) => year.toString())
    );

  // Add Y axis
  var yScaleLeft = d3
    .scaleLinear()
    .domain([
      0,
      d3.max(
        dataLeft.flatMap((d) => d.values),
        (d) => d[yField]
      )
    ])
    .range([height, 0]);

  var yScaleRight = d3
    .scaleLinear()
    .domain([
      0,
      d3.max(
        dataRight.flatMap((d) => d.values),
        (d) => d[yField]
      )
    ])
    .range([height, 0]);

  svgInner
    .append("g")
    .attr("class", "d3-axis d3-axis-right")
    .call(d3.axisLeft(yScaleLeft));

  svgInner
    .append("g")
    .attr("class", "d3-axis d3-axis-right")
    .attr("transform", `translate(${width}, 0)`)
    .call(d3.axisRight(yScaleRight));

  // Color palette
  var res = groupedData.map((d) => d.key).sort(); // list of group names

  var color = d3.scaleOrdinal().domain(res).range(palette);

  svgInner
    .append("g")
    .attr("transform", `translate(${margin.left}, ${margin.top})`);

  // Draw the line
  svgInner
    .selectAll(".line")
    .data(dataLeft)
    .enter()
    .append("path")
    .attr("fill", "none")
    .attr("data-legend", (d) => d.key)
    .attr("stroke", (d) => color(d.key))
    .attr("stroke-width", 1.5)
    .attr("d", function(d) {
      return d3
        .line()
        .x((d) => xScale(d[xField]))
        .y((d) => yScaleLeft(+d[yField]))(d.values);
    });

  svgInner
    .selectAll(".line")
    .data(dataRight)
    .enter()
    .append("path")
    .attr("fill", "none")
    .attr("data-legend", (d) => d.key)
    .attr("stroke", (d) => color(d.key))
    .attr("stroke-width", 1.5)
    .attr("d", function(d) {
      return d3
        .line()
        .x((d) => xScale(d[xField]))
        .y((d) => yScaleRight(+d[yField]))(d.values);
    });

  let legend = svgInner
    .append("g")
    .attr("class", "legend")
    .attr("transform", "translate(280,20)")
    .style("font-size", "12px")
    .call(d3.legend);
});
:root {
  --chart-background: #272727;
  --chart-foreground: #d7d7d7;
}

.d3-chart {
  background: var(--chart-background);
}

.d3-chart .d3-axis line,
.d3-chart .d3-axis path {
  stroke: var(--chart-foreground);
}

.d3-chart .d3-axis text {
  fill: var(--chart-foreground);
}

.d3-chart .legend-box {
  fill: none;
}

.d3-chart .legend .legend-items text {
  fill: var(--chart-foreground);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js"></script>
<div class="d3-chart">
  <svg width="400px" height="200px" />
</div>

Upvotes: 2

Related Questions