Adapted from on a divergent bar chart by @wpoely86. Ported to d3js v5. Moved neutrals values to a separate group, as per the recommendations given in "The case against diverging stacked bars".
forked from widged's block: Diverging Stacked Bar Chart
license: mit |
Adapted from on a divergent bar chart by @wpoely86. Ported to d3js v5. Moved neutrals values to a separate group, as per the recommendations given in "The case against diverging stacked bars".
forked from widged's block: Diverging Stacked Bar Chart
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<title>Diverging Stacked Bar Chart with D3.js</title> | |
<style> | |
body { | |
font: 10px sans-serif; | |
} | |
.axis path, | |
.axis line { | |
fill: none; | |
stroke: #000; | |
shape-rendering: crispEdges; | |
} | |
.axis.zero line { | |
stroke: #808080; | |
stroke-width: 1; | |
} | |
.legendbox { | |
height: 16px; | |
line-height: 16px; | |
} | |
.legendbox .legend { | |
position: relative; | |
display: inline-block; | |
padding-right: 16px; | |
} | |
.legendbox .legend .rect { | |
display: inline-block; | |
width: 32px; | |
height: 16px; | |
margin: 0; padding: 0; | |
} | |
.legendbox .legend .label { | |
display: inline-block; | |
transform: translateY(-20%); | |
font: "10px sans-serif"; | |
margin-left: 8px; | |
position: relative; | |
} | |
</style> | |
<script src="https://d3js.org/d3.v5.min.js"></script> | |
<body> | |
<div id="figure" style="margin-bottom: 50px;"></div> | |
<script> | |
const svgChart = (props, data) => { | |
const { width, height, margin, id, selector } = props; | |
var svg = d3 | |
.select(selector) | |
.append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
.attr("id", id) | |
.append("g") | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); | |
return { | |
svg, | |
width: width - margin.left - margin.right, | |
height: height - margin.top - margin.bottom | |
}; | |
}; | |
const decimalRounder = n => { | |
const rounder = Math.pow(10, n); | |
return d => { | |
return Math.round(d * rounder) / rounder; | |
}; | |
}; | |
const keyConfig = [ | |
// { csv: "1", g: "-", color: "#c7001e", label: "Strongly disagree" }, | |
{ csv: "2", g: "-", color: "#f6a580", label: "Unfavorable" }, | |
{ csv: "3", g: "n", color: "#cccccc", label: "Neutral" }, | |
{ csv: "4", g: "+", color: "#92c6db", label: "Favorable" }, | |
// { csv: "5", g: "+", color: "#086fad", label: "Strongly agree" } | |
]; | |
const ratioOfLikert = (keys, getN, data) => { | |
const ratioRounder = decimalRounder(3); | |
const cumulRounder = decimalRounder(5); | |
return data.map(d => { | |
return keys.reduce( | |
(acc, k, i, arr) => { | |
const { obs, ratio, N } = acc; | |
const raw = parseInt(d[k], 10); | |
obs[i] = raw; | |
ratio[i] = ratioRounder(raw / N); | |
return acc; | |
}, | |
{ obs: [], ratio: [], N: getN(d), label: d.Question } | |
); | |
}); | |
}; | |
const addDataToGroup = rawsAndRatios => { | |
return group => { | |
const { key, series } = group; | |
const items = rawsAndRatios.map((d, i) => { | |
let x0 = 0, | |
x1; | |
return series.map(g => { | |
const { idx } = g; | |
const obs = d.obs[idx], | |
ratio = d.ratio[idx]; | |
x1 = x0 + ratio; | |
const r = { obs, ratio, x0, x1 }; | |
x0 = x1; | |
return r; | |
}); | |
}); | |
const maxes = items.map((d, i) => { | |
return d[d.length - 1].x1; | |
}); | |
return { group: key, series, data: items, maxes }; | |
}; | |
}; | |
const plotHorizontalHtmlLegend = props => { | |
const { parentNode, data, className } = props; | |
const g = d3 | |
.select(parentNode) | |
.append("div") | |
.attr("class", className); | |
var item = g | |
.selectAll(".legend") | |
.data(data) | |
.enter() | |
.append("div") | |
.attr("class", "legend"); | |
item | |
.append("div") | |
.attr("class", "rect") | |
.style("background-color", d => { | |
return d.color; | |
}); | |
item | |
.append("div") | |
.attr("class", "label") | |
.text(d => { | |
return d.label; | |
}); | |
// d3.selectAll(".legendbox").attr("transform", "translate(" + movesize + ",0)"); | |
}; | |
const plotYAxis = props => { | |
const { svg, className, yScale } = props; | |
svg | |
.append("g") | |
.attr("class", className) | |
.call(d3.axisLeft(yScale)); | |
}; | |
const plotZeroLine = props => { | |
const { svg, className, x, ys } = props; | |
const [y1, y2] = ys; | |
svg | |
.append("g") | |
.attr("class", className) | |
.append("line") | |
.attr("x1", x) | |
.attr("x2", x) | |
.attr("y1", y1) | |
.attr("y2", y2); | |
}; | |
const serieExtent = data => { | |
const xMin = d3.min(data, d => { | |
return d[0].x0; | |
}); | |
const xMax = d3.max(data, d => { | |
return d[d.length - 1].x1; | |
}); | |
return [xMin, xMax]; | |
}; | |
const plotGroup = (data, svg, config) => { | |
const { | |
getQuestionTransform, | |
getQuestionAxis, | |
getBarX, | |
getBarWidth, | |
getBarHeight, | |
getBarText, | |
getBarColor | |
} = config; | |
svg | |
.append("g") | |
.attr("class", "x axis") | |
.call( | |
getQuestionAxis().tickFormat(d => { | |
return d3.format(".0%")(Math.abs(d)); | |
}) | |
); | |
const g = svg | |
.selectAll(".question") | |
.data(data) | |
.enter() | |
.append("g") | |
.attr("class", "question") | |
.attr("transform", getQuestionTransform); | |
var bars = g | |
.selectAll("rect") | |
.data((d, i) => { | |
return d; | |
}) | |
.enter() | |
.append("g") | |
.attr("class", "subbar"); | |
bars | |
.append("rect") | |
.attr("height", getBarHeight) | |
.attr("x", getBarX) | |
.attr("width", getBarWidth) | |
.style("fill", getBarColor); | |
bars | |
.append("text") | |
.attr("x", getBarX) | |
.attr("y", () => { | |
return getBarHeight() / 2; | |
}) | |
.attr("dy", "0.5em") | |
.attr("dx", "0.5em") | |
.style("text-anchor", "begin") | |
.text(getBarText); | |
}; | |
const plotRest = data => { | |
rows | |
.insert("rect", ":first-child") | |
.attr("height", bandwidth) | |
.attr("x", "1") | |
.attr("width", width) | |
.attr("fill-opacity", "0.5") | |
.style("fill", "#F5F5F5") | |
.attr("class", function(d, index) { | |
return index % 2 == 0 ? "even" : "uneven"; | |
}); | |
}; | |
const computeLayout = props => { | |
const { | |
alignRight, | |
xScale, | |
yScale, | |
maxes, | |
data, | |
series, | |
questionLabels | |
} = props; | |
const getY = i => { | |
return yScale(questionLabels[i]); | |
}; | |
const getBarHeight = () => { | |
return yScale.bandwidth(); | |
}; | |
const colorScale = d3.scaleOrdinal().range( | |
series.map(d => { | |
return d.color; | |
}) | |
); | |
const getQuestionX = (d, i) => { | |
return; | |
}; | |
const getQuestionTransform = (d, i) => { | |
const x = alignRight ? xScale(-maxes[i]) : 0; | |
const y = getY(i); | |
return `translate(${x},${y} )`; | |
}; | |
const getQuestionAxis = (d, i) => { | |
const [d0, d1] = xScale.domain(); | |
const [r0, r1] = xScale.range(); | |
const domain = alignRight ? [-d1, -d0] : [d0, d1]; | |
const range = alignRight ? [-r1, -r0] : [r0, r1]; | |
const scale = d3 | |
.scaleLinear() | |
.domain(domain) | |
.rangeRound(range) | |
.nice(); | |
return d3.axisTop(scale).tickValues( | |
d3.range(0, d3.max(maxes) + 0.05, 0.1).map(d => { | |
return alignRight ? -d : d; | |
}) | |
); | |
}; | |
const getBarX = (d, i) => { | |
return xScale(d.x0); | |
}; | |
const getBarWidth = d => { | |
return Math.abs(xScale(d.x1) - xScale(d.x0)); | |
}; | |
const getBarText = d => { | |
return d.n !== 0 && getBarWidth(d) > 0.3 ? d.obs : ""; | |
}; | |
const getBarColor = (d, i) => { | |
// console.log(d, i, series[i].label); | |
return colorScale(series[i].label); | |
}; | |
return { | |
getQuestionTransform, | |
getQuestionAxis, | |
getBarX, | |
getBarWidth, | |
getBarHeight, | |
getBarText, | |
getBarColor | |
}; | |
}; | |
d3.csv("raw_data.csv").then(function(data) { | |
const rawsAndRatios = ratioOfLikert( | |
keyConfig.map(d => { | |
return d.csv; | |
}), | |
d => { | |
return d.N; | |
}, | |
data | |
); | |
const groups = keyConfig.reduce( | |
(acc, d, i) => { | |
const { ks, groups } = acc; | |
const { g, csv, color, label } = d; | |
let idx = ks.indexOf(g); | |
if (idx === -1) { | |
idx = ks.length; | |
ks.push(g); | |
} | |
if (!groups[idx]) { | |
groups[idx] = { key: g, series: [] }; | |
} | |
groups[idx].series.push({ idx: i, csv, color, label }); | |
return { ks, groups }; | |
}, | |
{ ks: [], groups: [] } | |
).groups; | |
const groupsWithData = groups.map(addDataToGroup(rawsAndRatios)); | |
plotHorizontalHtmlLegend({ | |
parentNode: document.querySelector("#figure"), | |
data: keyConfig.map(d => { | |
return { label: d.label, color: d.color }; | |
}), | |
center: 50, | |
className: "legendbox" | |
}); | |
const { svg, width, height } = svgChart({ | |
margin: { top: 50, right: 20, bottom: 10, left: 65 }, | |
width: 800, | |
height: 500, | |
selector: "#figure", | |
id: "d3-plot" | |
}); | |
const questionLabels = rawsAndRatios.map(function(d) { | |
return d.label; | |
}); | |
const yScale = d3 | |
.scaleBand() | |
.rangeRound([0, height]) | |
.padding(0.3) | |
.domain(questionLabels); | |
const xEnd = d3.sum(groupsWithData, d => { | |
return d3.max(d.maxes); | |
}); | |
const leftPadding = 32; | |
const neutralPadding = 96; | |
const xScale = d3 | |
.scaleLinear() | |
.domain([0, xEnd]) | |
.rangeRound([0, width - neutralPadding - leftPadding]) | |
.nice(); | |
plotYAxis({ svg, className: "y axis", yScale }); | |
const svgGroup = svg | |
.append("g") | |
.attr("class", "plot") | |
.attr("transform", "translate(" + leftPadding + "," + 0 + ")"); | |
const config = { | |
xScale, | |
yScale, | |
questionLabels | |
}; | |
let groupData, xOffset, xMax; | |
// -- group | |
groupData = groupsWithData[0]; | |
xOffset = xScale(d3.max(groupsWithData[0].maxes)); | |
plotGroup( | |
groupData.data, | |
svgGroup | |
.append("g") | |
.attr("class", "negative") | |
.attr("transform", "translate(" + xOffset + "," + 0 + ")"), | |
computeLayout( | |
Object.assign({}, config, groupData, { | |
alignRight: true | |
}) | |
) | |
); | |
// -- group | |
groupData = groupsWithData[2]; | |
xOffset = xScale(d3.max(groupsWithData[0].maxes)); | |
plotGroup( | |
groupData.data, | |
svgGroup | |
.append("g") | |
.attr("class", "positive") | |
.attr("transform", "translate(" + xOffset + "," + 0 + ")"), | |
computeLayout(Object.assign({}, config, groupData)) | |
); | |
plotZeroLine({ | |
svg: svgGroup, | |
className: "zero axis", | |
x: xOffset, | |
ys: [0, height] | |
}); | |
// -- group | |
groupData = groupsWithData[1]; | |
xOffset = | |
xScale(d3.max(groupsWithData[0].maxes)) + | |
neutralPadding + | |
xScale(d3.max(groupsWithData[2].maxes)); | |
plotGroup( | |
groupData.data, | |
svgGroup | |
.append("g") | |
.attr("class", "neutral") | |
.attr("transform", "translate(" + xOffset + "," + 0 + ")"), | |
computeLayout(Object.assign({}, config, groupData)) | |
); | |
}); | |
</script> | |
</body> | |
</html> |
Question | 1 | 2 | 3 | 4 | 5 | N | |
---|---|---|---|---|---|---|---|
Question 1 | 0 | 17 | 26 | 57 | 0 | 100 | |
Question 2 | 0 | 8 | 20 | 72 | 0 | 100 | |
Question 3 | 0 | 11 | 32 | 57 | 0 | 100 | |
Question 4 | 0 | 5 | 12 | 83 | 0 | 100 | |
Question 5 | 0 | 3 | 12 | 85 | 0 | 100 | |
Question 6 | 0 | 13 | 22 | 65 | 0 | 100 | |
Question 7 | 0 | 6 | 17 | 77 | 0 | 100 | |
Question 8 | 0 | 4 | 10 | 86 | 0 | 100 | |
Question 9 | 0 | 1 | 17 | 82 | 0 | 100 | |
Question 10 | 0 | 8 | 15 | 77 | 0 | 100 | |
Question 11 | 0 | 25 | 50 | 25 | 0 | 100 | |
Question 12 | 0 | 13 | 53 | 34 | 0 | 100 |