Group D3 legend items

▼魔方 西西 提交于 2021-01-28 11:42:40

问题


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>

回答1:


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>


来源:https://stackoverflow.com/questions/64914477/group-d3-legend-items

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!