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 |