Reputation: 48600
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.
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
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