Éléments de légende du groupe D3

Nov 19 2020

Comment grouper le cercle et le texte de chaque élément de légende?

J'ai un problème avec enter/ exit. Je peux créer les groupes ( g), mais je ne peux pas ajouter les éléments text/ circle.

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))

Voici le balisage SVG rendu actuel:

<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>

Voici le balisage souhaité:

<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>

De cette façon, je peux ajouter un écouteur d'événement .legend-itemet utiliser l'attribut data keypour faire des références croisées avec la série de lignes.

Remarque: Le code de légende ci-dessous est une version mise à jour (6.2.0) de d3-legend . J'ai légèrement modifié le code.


Exemple

Voici un exemple de travail de ce que j'ai jusqu'à présent.

(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>

Réponses

2 RubenHelsloot Nov 19 2020 at 22:22

Le résultat de .enter()est une sélection qui a des datumvaleurs correspondantes , mais qui n’a aucun nœud associé. C'est pourquoi nous ne fusionnons normalement qu'au tout dernier moment, après avoir appelé .append("g").

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

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

Étant donné que vous ne pouvez pas ajouter gdeux fois, vous devez ajouter un gélément, puis un textélément, puis renvoyer le fichier g. Cela ressemblerait à quelque chose comme ceci:

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())

Cependant, puisque vous utilisez v6 d3, vous pouvez également utiliser la nouvelle .join()fonctionnalité, où vous pouvez appliquer des fonctions à votre .enter(), .exit()et les pièces de mise à jour (implicite) de la sélection, et qui renvoie le résultat de la fusion:

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()
  );

Cela conduit à l'extrait de code suivant:

(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>