Reputation: 591
Hey y'all. Here again to ask more questions about D3.
I have successfully created this stacked bar chart that displays each stacked bar chart by year. Now, I want to group these stacked bar charts together by group (CA, TX, and HI).
In the end, I want it to look kind of like this.
I have been banging my head against the wall for hours trying to adjust the X-Axis to group these appropriately. Can anyone help me?
Here were/are some assumptions I'm making
group
and then one for year
)d3.group
or d3.nest
(which isn't available in the newest version of d3 anyways) since my data is formatted in a way to have access to the group.Please help me out! Anything helps.
class D3GroupedStackedBarChart extends React.Component<Props, State> {
state: State = {
data: [
{group: "CA", year: 1993, males: 100, females: 95, pets: 12},
{group: "CA", year: 1994, males: 80, females: 88, pets: 8},
{group: "CA", year: 1995, males: 70, females: 30, pets: 34},
{group: "TX", year: 1996, males: 111, females: 122, pets: 32},
{group: "TX", year: 1997, males: 25, females: 25, pets: 64},
{group: "HI", year: 1998, males: 13, females: 45, pets: 72},
],
};
/*
https://bl.ocks.org/SpaceActuary/6233700e7f443b719855a227f4749ee5
*/
componentDidMount() {
const {data} = this.state;
const keys = ["males", "females", "pets"];
const groups = ["CA", "TX", "HI"];
const colors = {
males: "blue",
females: "pink",
pets: "green",
};
// Width and height of our original box
const width = 1000;
const height = 1000;
// Margin we want, and making the center SVG to hold our graph
const margin = {top: 80, right: 180, bottom: 80, left: 180};
const padding = 0.1;
// Creating a function to create layers
const stackGenerator = d3.stack().keys(keys); // now a function
// Creating layers from our data and keys
// keys = our layers
const layers = stackGenerator(data); // now a function
// Origin of an SVG is in the TOP LEFT corner
const svg = d3
.select("#test")
.append("svg") // append an svg element to our div#test
// Creating the actual height and width of our svg to hold report
.attr("height", height - margin.top - margin.bottom)
.attr("width", width - margin.left - margin.right)
.attr("viewBox", [0, 0, width, height])
.style("background-color", Color.white);
// SCALE (group)
const xScaleGroup = d3
.scaleBand()
.domain(data.map(d => d.group))
.range([margin.left, width - margin.right]);
// SCALE (year)
const xScale = d3
// Scaleband just means determined based off # of inputs
// and not off a huge range (that's for the y-axis)
.scaleBand()
// Complete set of values, so YEAR
.domain(data.map(d => d.year))
// Range is the remaining width of our SVG we want report ing
.range([margin.left, width - margin.right])
.padding(padding);
// looking at second value / y value
const extent = [
0.9 * d3.min(layers, layer => d3.min(layer, d => d[1])),
1.1 * d3.max(layers, layer => d3.max(layer, d => d[1])),
];
const [yMin, yMax] = extent;
const yScale = d3
.scaleLinear()
.domain(extent)
.range([height - margin.bottom, margin.top]); // range from bottom up
// AXIS
const xAxis = g => {
// bottom align it
g.attr("transform", `translate(0, ${height - margin.bottom})`)
.attr("class", "x-axis")
.call(d3.axisBottom(xScale))
.call(g => {
g.selectAll(".tick>line").remove();
})
.call(g => g.select(".domain").attr("d", "M180,0H820"))
.attr("font-size", "12px");
};
const yAxis = g => {
g.attr("transform", `translate(${margin.left - 20}, 0)`)
.attr("class", "y-axis")
.call(d3.axisLeft(yScale))
.call(g => g.selectAll(".domain").remove())
.call(g => {
g.selectAll(".tick>line")
.attr("x2", -50)
.attr("x1", -34);
})
.attr("font-size", "12px");
};
const yAxisLabel = g => {
g.append("text")
.attr("text-anchor", "start")
.attr("fill", "black")
.attr("font-size", "12px")
.attr("x", -40)
.attr("y", height - 60)
.text("UNITS");
};
// Create tooltip
const Tooltip = d3
.select("#test")
.append("div")
.style("opacity", 0)
.attr("class", css(styles.tooltip));
// Three function that change the tooltip when user hover / move / leave a cell
const mouseover = function(event, data) {
Tooltip.style("opacity", 1);
d3.select(this)
.style("stroke", "black")
.style("opacity", 1);
};
const createTooltipHtml = (key, year, value) => {
return ReactDOMServer.renderToStaticMarkup(
<>
<HeadingXSmall style={styles.tooltipHeader}>
5th {key} / 211-217
</HeadingXSmall>
<Body style={styles.tooltipSubheader}>
Identify coordinates
</Body>
<Body style={styles.infoContainer}>
<div
className={css(styles.box)}
style={{background: colors[key]}}
></div>
<Body style={styles.tooltipInfo}>
{year}: {value} things
</Body>
</Body>
<hr style={{margin: "24px 0"}}></hr>
<img
src={staticUrl("/images/districts/graph.png")}
alt={i18n._("Sample image for tooltip")}
style={styles.tooltipImage}
/>
</>,
);
};
const mousemove = function(event, data) {
const {0: start, 1: end, data: d} = data;
const {target, layerX: x, layerY: y} = event;
const layerKey = d3.select(target.parentNode).datum().key;
const tooltipHtml = createTooltipHtml(
layerKey,
d.year,
end - start,
);
Tooltip.html(tooltipHtml)
.style("left", x + 10 + "px")
.style("top", y - 10 + "px");
};
const mouseleave = function(event, data) {
Tooltip.style("opacity", 0);
d3.select(this)
.style("stroke", "none")
.style("opacity", 1);
};
// Creating Legend
const legend = svg
.append("g")
.attr("class", "legend")
.attr("transform", d => "translate(0, 0)")
.attr("font-size", "12px")
.attr("text-anchor", "start")
.selectAll("g")
.data(keys)
.join("g") // Create 3 "g" elements that are initially empty
.attr("transform", (d, i) => "translate(0," + i * 30 + ")");
// Add square and their color
legend
.append("rect") // append a rect to each individual g
.attr("fill", d => colors[d])
.attr("x", width - margin.right)
.attr("rx", 3)
.attr("width", 19)
.attr("height", 19);
// Add text next to squares
legend
.append("text")
.attr("x", width - margin.right + 40)
.attr("y", 9.5)
.attr("dy", "0.32em")
.text(d => d);
// Add header
const legendHeader = d3
.select(".legend")
.append("g")
.attr("transform", (d, i) => "translate(0, -20)")
.lower()
.append("text")
.attr("x", width - margin.right)
.attr("font-size", "12px")
.text(() => {
const text = "Master Levels";
return text.toLocaleUpperCase();
});
// Get coordinates and height of legend to add border
const {
x: legendX,
y: legendY,
width: legendWidth,
height: legendHeight,
} = d3
.select(".legend")
.node()
.getBBox();
const borderPadding = 20;
// Create border for legend
// Adding a "border" manually
const legendBox = svg
.select(".legend")
.append("rect")
.lower()
.attr("class", "legend-box")
.attr("x", legendX - borderPadding)
.attr("y", legendY - borderPadding)
.attr("width", legendWidth + borderPadding * 2)
.attr("height", legendHeight + borderPadding * 2)
.attr("fill", "white")
.attr("stroke", "black")
.attr("opacity", 0.8);
// Rendering
// first, second, and third refer to `layers`
// first --> layers
// second --> edge1, edge2, and data
svg.selectAll(".layer")
.data(layers) // first
.join("g") // create new element for each layer
.attr("class", "layer")
.attr("fill", layer => colors[layer.key])
.selectAll("rect")
.data(layer => layer) // second
.join("rect")
.attr("class", "series-rect")
.attr("x", d => xScale(d.data.year))
.attr("y", d => yScale(d[1]))
.attr("width", xScale.bandwidth())
.attr("height", (d, i, els) => {
const [lower, upper] = d;
const firstBarAdjustment = lower === 0 ? yMin : 0;
return yScale(lower + firstBarAdjustment) - yScale(upper);
})
.on("mouseover", mouseover)
.on("mousemove", mousemove)
.on("mouseleave", mouseleave);
svg.append("g").call(xAxis);
svg.append("g")
.call(yAxis)
.call(yAxisLabel);
svg.node();
}
render(): React.Node {
return (
<View>
<LabelLarge>{i18n.doNotTranslate("D3.js")}</LabelLarge>
<Strut size={Spacing.xLarge_32} />
<div id="test" />
</View>
);
}
}
Upvotes: 2
Views: 868
Reputation: 135
This might not be the cleanest solution but I have done this before and it works reliably. Here is super rough hack of your code that I just threw together. I left a few inline comments. (jsfiddle)
const data = [{
group: "CA",
year: 1993,
males: 100,
females: 95,
pets: 12
},
{
group: "CA",
year: 1994,
males: 80,
females: 88,
pets: 8
},
{
group: "CA",
year: 1995,
males: 70,
females: 30,
pets: 34
},
{
group: "TX",
year: 1996,
males: 111,
females: 122,
pets: 32
},
{
group: "TX",
year: 1997,
males: 25,
females: 25,
pets: 64
},
{
group: "HI",
year: 1998,
males: 13,
females: 45,
pets: 72
},
]
const keys = ["males", "females", "pets"];
const colors = {
males: "blue",
females: "pink",
pets: "green",
TX: "red",
HI: "purple",
CA: "yellow"
};
// Width and height of our original box
const width = 1000;
const height = 1000;
// Margin we want, and making the center SVG to hold our graph
const margin = {
top: 80,
right: 180,
bottom: 80,
left: 180
};
const padding = 0.1;
const dataByState = d3.group(data, d => d.group)
const dataByYear = d3.group(data, d => d.year)
// Creating a function to create layers
const stackGenerator = d3.stack().keys(keys); // now a function
// Creating layers from our data and keys
// keys = our layers
const layers = stackGenerator(data); // now a function
// Origin of an SVG is in the TOP LEFT corner
const svg = d3
.select("#test")
.append("svg") // append an svg element to our div#test
// Creating the actual height and width of our svg to hold report
.attr("height", height - margin.top - margin.bottom)
.attr("width", width - margin.left - margin.right)
.attr("viewBox", [0, 0, width, height])
.style("background-color", "white")
// Create an outer axis that we will use to group initially
const outerGroupXScale = d3.scaleBand()
.domain(dataByState.keys())
.range([margin.left, width - margin.right])
.padding(0.05)
const outerGroupXAxis = g => {
// bottom align it
g.attr("transform", `translate(0, ${height - margin.bottom/2})`)
.attr("class", "x-axis")
.call(d3.axisBottom(outerGroupXScale))
.call(g => {
g.selectAll(".tick>line").remove();
})
.call(g => g.select(".domain").attr("d", "M180,0H820"))
.attr("font-size", "12px");
};
// Create an inner axis that we will use inside the outer group. Note that the width is the outer scale bandwidth
// and this scale is not concerned with the entire graph width.
const innerGroupXScale = d3.scaleBand()
.domain(dataByYear.keys())
.range([0, outerGroupXScale.bandwidth()])
.padding(0.05)
const innerGroupXAxis = g => {
// bottom align it
g.attr("transform", `translate(0, ${height - margin.bottom})`)
.attr("class", "x-axis")
.call(d3.axisBottom(innerGroupXScale))
.attr("font-size", "12px");
};
// looking at second value / y value
const extent = [
0.9 * d3.min(layers, layer => d3.min(layer, d => d[1])),
1.1 * d3.max(layers, layer => d3.max(layer, d => d[1])),
];
const [yMin, yMax] = extent;
const yScale = d3
.scaleLinear()
.domain(extent)
.range([height - margin.bottom, margin.top]); // range from bottom up
const yAxis = g => {
g.attr("transform", `translate(${margin.left - 20}, 0)`)
.attr("class", "y-axis")
.call(d3.axisLeft(yScale))
.call(g => g.selectAll(".domain").remove())
.call(g => {
g.selectAll(".tick>line")
.attr("x2", -50)
.attr("x1", -34);
})
.attr("font-size", "12px");
};
const yAxisLabel = g => {
g.append("text")
.attr("text-anchor", "start")
.attr("fill", "black")
.attr("font-size", "12px")
.attr("x", -40)
.attr("y", height - 60)
.text("UNITS");
};
// create the initially grouping by binding to the data grouped by state
var stateG = svg.selectAll(".state")
.data(dataByState)
.join("g")
.attr("class", "state")
.attr("fill", d => colors[d[0]])
.attr("transform", d => `translate(${outerGroupXScale(d[0])}, 0)`)
// draw the inner x axis on the state group because we will have one per state group
stateG.append("g").attr("class", "stateAxis").call(innerGroupXAxis);
// create the year groups inside the initial grouping of states and offset them
// based on which state they belong to
var yearG = stateG.selectAll(".yearG")
.data(d => {
const filteredByState = data.filter(i => i.group === d[0])
const groupedByYear = d3.group(filteredByState, a => a.year)
return groupedByYear
})
.join("g")
.attr("class", "yearG")
.attr("transform", d => {
return `translate(${innerGroupXScale(d[0])}, 0)`
})
// for each year put down your layers
yearG.selectAll(".layers")
.data(d => {
return stackGenerator(d[1])
})
.join("rect")
.attr("class", "layers")
.attr("y", d => yScale(d[0][1]))
.attr("fill", d => colors[d.key])
.attr("width", d => innerGroupXScale.bandwidth())
.attr("height", d => {
const lower = d[0][0]
const upper = d[0][1];
const firstBarAdjustment = lower === 0 ? yMin : 0;
return yScale(lower + firstBarAdjustment) - yScale(upper);
})
svg.append("g").call(outerGroupXAxis);
svg.append("g")
.call(yAxis)
.call(yAxisLabel);
svg.node();
The main idea is that you want a main x scale for the outer grouping (in this case states) and then an x scale that scales on the inner grouping (in this case years) that uses the bandwidth of the outer x scale as its range.
Once you have the two x scales in place the rest of it is just your normal d3 databinding pattern. So in your example the steps would be:
See the jsfiddle I linked above for a working version of your example. Note in your diagram it skips empty columns which makes this more tricky and less readable to a user as the x axis would not be consistent. If you really wanted to do that you would have to create independent x scales for each set of grouped data via a loop.
Upvotes: 3