block by widged a58fdeb7c2357c16f4071dcc3889ea84

Diverging Stacked Bar Chart

Full Screen

Diverging Stacked Bar Chart

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”.

index.html

<!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: "Disagree" },
  { csv: "3", g: "n", color: "#cccccc", label: "Neither agree nor disagree" },
  { csv: "4", g: "+", color: "#92c6db", label: "Agree" },
  { 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>

raw_data.csv

Question,1,2,3,4,5,N
Question 1,24,294,594,1927,376,3215
Question 2,2,2,0,7,0,11
Question 3,2,0,2,4,2,10
Question 4,0,2,1,7,6,16
Question 5,0,1,3,16,4,24
Question 6,1,1,2,9,3,16
Question 7,0,0,1,4,0,5
Question 8,0,0,0,0,2,2