Reputation: 25426
I have a pretty simple nested data structure that I want to render as a bar chart with d3. The "nesting" is that each array element itself contains an array:
interface MyData {
name: string
values: number[]
}
const allData: MyData[] = [
{ name: "one", values: [4, 6, 10] },
{ name: "two", values: [1, 3, 4, 7] },
{ name: "three", values: [2, 2.5, 4] },
{ name: "four", values: [5, 8, 9] },
{ name: "five", values: [3, 5, 5.5, 6, 9] }
]
I want to render this in svg with d3 such that it looks something like this, with discrete lines for each value
in the values array and a container rectangle containing the range of values, and I want to be able to toggle the visibility of individual elements in my main data array:
I have cobbled together a working version, creating the container rects and the point lines in separate steps, and I feel like I'm missing how to render these in a simpler way (codesandbox here).
// Range container rectangle
svg
.select("g.plot-area")
.selectAll("rect.range")
.data(allData, (d: any) => d.name)
.join(
(enter) =>
enter
.append("rect")
.classed("range", true)
.attr("all my attrs for the range rect", ...)
(update) =>
update
.attr("attrs for updated range rect", ...),
(exit) => exit.remove()
);
// Individual point lines
svg
.select("g.plot-area")
.selectAll("g.points")
.data(allData, (d: any) => d.name) // <-- this seems weird
.join(
(enter) => {
const points = enter.append("g").classed("points", true);
points
.selectAll("rect.point")
.data((d) => d.values)
.join("rect")
.classed("point", true)
.attr("attrs for this point", ...)
return points;
},
(update) => {
update
.selectAll("rect.point")
.attr("attrs for this point", ...)
return update;
}
);
In particular, for the individual points it seems dumb that I have to bind to the main data set again for no other reason than to subsequently bind to each array of points. I feel like this should be able to happen with fewer steps.
How can I most effectively use d3 to render both the container rects and the individual point rects?
Upvotes: 2
Views: 103
Reputation: 19299
Since you are only drawing rect
s then simply un-nesting the data seems a viable approach. The only difference between your .range
and .point
rect
s is only in their opacity and width.
E.g.
const unnestedData = allData.reduce((a, c) => {
a.push({
name: c.name,
color: c.color,
type: "range",
min: d3.min(c.values),
max: d3.max(c.values)
});
c.values.map(n => {
a.push({
name: c.name,
color: c.color,
type: "line",
min: n,
max: n // redundant
});
});
return a;
}, []);
Returns:
0: {name: "one", color: "red", type: "range", min: 4, max: 10}
1: {name: "one", color: "red", type: "line", min: 4, max: 4}
2: {name: "one", color: "red", type: "line", min: 6, max: 6}
3: {name: "one", color: "red", type: "line", min: 10, max: 10}
4: {name: "two", color: "green", type: "range", min: 1, max: 7}
5: {name: "two", color: "green", type: "line", min: 1, max: 1}
6: {name: "two", color: "green", type: "line", min: 3, max: 3}
7: {name: "two", color: "green", type: "line", min: 4, max: 4}
8: {name: "two", color: "green", type: "line", min: 7, max: 7}
... etc
Then you only need a single cycle and employ a couple of conditions for opacity
and width
attributes:
svg.selectAll(".rects")
.data(unnestedData) // d => d
.join(
enter =>
enter.append("rect")
.attr("class", d => d.type)
.attr("opacity", d => d.type === "range" ? 0.3 : 1)
.attr("fill", d => d.color)
.attr("x", d => xScale(d.min))
.attr("y", d => yScale(d.name))
.attr("width", d => d.type === "range" ? xScale(d.max) - xScale(d.min) : 2)
.attr("height", yScale.bandwidth())
)
Example:
// input data
const allData = [
{ name: "one", values: [4, 6, 10], color: "red" },
{ name: "two", values: [1, 3, 4, 7], color: "green" },
{ name: "three", values: [2, 2.5, 4], color: "cyan" },
{ name: "four", values: [5, 8, 9], color: "purple" },
{ name: "five", values: [3, 5, 5.5, 6, 9], color: "orange" }
];
// un-nest the data
const unnestedData = allData.reduce((a, c) => {
a.push({
name: c.name,
color: c.color,
type: "range",
min: d3.min(c.values),
max: d3.max(c.values)
});
c.values.map(n => {
a.push({
name: c.name,
color: c.color,
type: "line",
min: n,
max: n // redundant
});
});
return a;
}, []);
// basic viz
const width = 500;
const height = 200;
const margin = {top: 5, right: 5, bottom: 20, left: 50}
const svg = d3.select("#graph")
.append("svg")
.attr("width", width)
.attr("height", height);
const xScale = d3.scaleLinear()
.domain([1, 10])
.range([margin.left, width - margin.right]);
const xAxis = svg.append("g")
.attr("transform", `translate(0, ${height - margin.bottom})`)
.call(d3.axisBottom(xScale));
const yScale = d3.scaleBand()
.padding(0.1)
.domain(allData.map(k => k.name))
.range([margin.top, height - margin.bottom]);
const yAxis = svg.append("g")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(yScale));
const plotG = svg.append("g")
.attr("class", "plot-area");
// render
svg.selectAll(".rects")
.data(unnestedData, d => d)
.join(
enter =>
enter
.append("rect")
.attr("class", d => d.type)
.attr("opacity", d => d.type === "range" ? 0.3 : 1)
.attr("fill", d => d.color)
.attr("x", d => xScale(d.min))
.attr("y", d => yScale(d.name))
.attr("width", d => d.type === "range" ? xScale(d.max) - xScale(d.min) : 2)
.attr("height", yScale.bandwidth())
)
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<div id="graph"></div>
Upvotes: 2