blob: 3ed657e4c275cf1c520eed69879f202ec8459c00 [file] [log] [blame]
'use strict';
/**
* Concrete implementation of Plotter strategy for creating
* stacked bar charts.
* @implements {Plotter}
*/
class StackedBarPlotter {
constructor() {
/** @private @const {number} name_
* The column in a datasource data referring to the
* name of the associated data.
*/
this.name_ = 0;
/** @private @const {number} data_
* The column in a datasource referring to the data.
*/
this.data_ = 1;
/**
* @private @const {Array<string>} colors_
* An array of hexcodes representing the available color
* options for stacks in the plot.
*/
this.colors_ = d3.schemeCategory10;
/**
* @private @const {Object}
* Maps stack names to their colors, which are assigned
* from the colors array.
*/
this.colorForStack_ = {};
}
/**
* Initalises the chart by computing the scales for the axes and
* drawing them. It also applies the labels to the graph (axes and
* graph titles).
* @param {GraphData} graph GraphData instance containing the raw data.
* @param {Object} chart Chart to draw to.
* @param {Object} chartDimensions Size of the chart.
* @param {Object} data The processed data.
*/
initChart_(graph, chart, chartDimensions, data) {
this.outerBandScale_ = this.createXAxisScale_(graph, chartDimensions);
this.scaleForYAxis_ = this.createYAxisScale_(chartDimensions, data);
this.xAxisGenerator_ = d3.axisBottom(this.outerBandScale_);
this.yAxisGenerator_ = d3.axisLeft(this.scaleForYAxis_);
// Note that the group for the xaxis which is transformed must be
// wrapped in another group. This is to have a g element which
// preserves the co-ordinate system of the chart, allowing a
// clip path to be used based on the co-ordinate system of the chart
// rather than the transformed system of the axis.
chart.append('g')
.attr('class', 'xaxis')
.append('g')
.call(this.xAxisGenerator_)
.attr('transform', `translate(0, ${chartDimensions.height})`);
this.yAxisDrawing_ = chart.append('g')
.call(this.yAxisGenerator_);
this.keys_ = graph.keys();
// Each story is assigned a band by the createXAxisScale function
// which maintains the positions in which each category of the chart
// will be rendered. This further divides these bands into
// sub-bands for each data source, so that different labels can be
// grouped within the same category.
this.innerBandScale_ = this.createInnerBandScale_(graph);
}
getBarCategories_(graph) {
// Process data sources so that their data contains only their categories.
const dataSources = graph.process(data => data.map(row => row[this.name_]));
if (dataSources.length > 0) {
return dataSources[0].data;
}
return [];
}
createXAxisScale_(graph, chartDimensions) {
return d3.scaleBand()
.domain(this.getBarCategories_(graph))
.range([0, chartDimensions.width])
.padding(0.2);
}
createYAxisScale_(chartDimensions, sources) {
const getStackHeights =
stackNames => stackNames.map(stack => stack[this.data_].height);
const getBarHeight =
story => this.sum_(getStackHeights(story[this.data_]));
const maxHeight =
bars => d3.max(bars.map(story => getBarHeight(story)));
const maxHeightOfAllSources =
d3.max(sources.map(({ data }) => maxHeight(data)));
const defaultRange = 1;
return d3.scaleLinear()
.domain([Math.round(maxHeightOfAllSources) || defaultRange, 0]).nice()
.range([0, chartDimensions.height]);
}
createInnerBandScale_() {
return d3.scaleBand()
.domain(this.keys_)
.range([0, this.outerBandScale_.bandwidth()]);
}
/**
* Attempts to compute the average of the given numbers but returns
* 0 if the supplied array is empty.
* @param {Array<number>} data
* @returns {number}
*/
avg_(data) {
return this.sum_(data) / data.length || 0;
}
sum_(data) {
if (!data.every(val => typeof val === 'number')) {
throw new TypeError('Expected an array of numbers.');
}
return data.reduce((a, b) => a + b, 0);
}
getColorForStack_(name) {
if (!(name in this.colorForStack_)) {
const numColors = this.colors_.length;
const numAssigned = Object.entries(this.colorForStack_).length;
this.colorForStack_[name] = this.colors_[numAssigned % numColors];
}
return this.colorForStack_[name];
}
/**
* Computes the start position and height of each stack for
* the bar corresponing to the supplied data.
* @param {Object} data The data for a single bar. Each key
* corresponds to the label for the stack and the value that
* key represents the values associated with that label.
* For example:
* {
* stackOne: [numbers...]
* stackTwo: [numbers...]
* }
* will compute the positions for a bar that has two stacks,
* the height of each being the average of numbers...
*/
stackLocations_(data) {
const stackedAverages = [];
let cumulativeAvg = 0;
const compareStackNames = ([a], [b]) => a.localeCompare(b);
const stacks = Object.entries(data).sort(compareStackNames);
for (const [name, values] of stacks) {
const average = this.avg_(values);
const yValues = {
start: cumulativeAvg,
height: average,
};
cumulativeAvg += average;
stackedAverages.push([name, yValues]);
}
return stackedAverages;
}
getNumericLabelForKey_(key) {
return this.keys_.indexOf(key) + 1;
}
/**
* Draws a stacked bar chart to the canvas. This expects the data
* in graph to be formatted as a table, with the first column being categories
* and the second being the corresponding values.
* @param {GraphData} graph The data to be plotted.
* @param {Object} chart d3 selection for the chart element to be drawn on.
* @param {Object} legend d3 selection for the legend element for
* additional information to be drawn on.
* @param {Object} chartDimensions The margins, width and height
* of the chart. This is useful for computing appropriates axis
* scales and positioning elements.
*/
plot(graph, chart, legend, chartDimensions) {
const formatBars = bars =>
bars.map(
([story, values]) => [story, this.stackLocations_(values)]);
const bars = graph.process(formatBars);
this.initChart_(graph, chart, chartDimensions, bars);
bars.forEach(({ data, key }) => {
data.forEach((bars) => {
this.drawStackedBar_(chart, bars, chartDimensions, key, graph);
});
});
if (bars.length > 0) {
const bar = bars[0];
const stacks = bar.data;
if (stacks.length > 0) {
const stackNames = stacks[0][this.data_].map(([name]) => name);
this.drawLegendLabels_(legend, stackNames);
}
}
const tickRotation = -30;
d3.select('.xaxis')
.attr('clip-path', 'url(#regionForXAxisTickText)');
d3.selectAll('.xaxis text')
.attr('text-anchor', 'end')
.attr('font-size', 12)
.attr('transform', `rotate(${tickRotation})`)
.append('title')
.text(text => text);
}
drawLegendLabels_(legend, stackNames) {
stackNames.forEach((stackName, i) => {
const boxSize = 10;
const offset = `${i * 1}em`;
legend.append('rect')
.attr('fill', this.getColorForStack_(stackName))
.attr('height', boxSize)
.attr('width', boxSize)
.attr('y', offset)
.attr('x', 0);
legend.append('text')
.text(stackName)
.attr('x', boxSize)
.attr('y', offset)
.attr('dy', boxSize)
.attr('text-anchor', 'start');
});
this.keys_.forEach((key, i) => {
const belowStackNames = `${stackNames.length + 1}em`;
const offset = `${i}em`;
legend.append('text')
.text(`${this.getNumericLabelForKey_(key)}: ${key}`)
.attr('y', belowStackNames)
.attr('dy', offset)
.append('title')
.text(key);
});
}
drawStackedBar_(selection, bars, chartDimensions, key, graph) {
const barName = bars[this.name_];
const stacks = bars[this.data_];
const x = this.outerBandScale_(barName) + this.innerBandScale_(key);
let totalHeight = 0;
stacks.forEach(stack => {
const positions = stack[this.data_];
const height =
chartDimensions.height - this.scaleForYAxis_(positions.height);
selection.append('rect')
.attr('x', x)
.attr('y', this.scaleForYAxis_(positions.height + positions.start))
.attr('width', this.innerBandScale_.bandwidth())
.attr('height', height)
.attr('fill', this.getColorForStack_(stack[this.name_]))
.on('click', () =>
graph.interactiveCallbackForCategory(stack[this.name_]))
.on('mouseover', function() {
d3.select(this).attr('opacity', 0.5);
})
.on('mouseout', function() {
// Removes the opacity attribute.
d3.select(this).attr('opacity', null);
})
.append('title')
.text(stack[this.name_]);
totalHeight += positions.height;
});
const barMid = x + this.innerBandScale_.bandwidth() / 2;
const padding = 5;
selection.append('text')
.text(this.getNumericLabelForKey_(key))
.attr('fill', 'black')
.attr('y', this.scaleForYAxis_(totalHeight) - padding)
.attr('x', barMid)
.append('title')
.text(key);
}
}