import { select as d3Select } from 'd3-selection';
import { scaleLinear as d3ScaleLinear } from 'd3-scale';
import { axisBottom as d3AxisBottom } from 'd3-axis';
import OECDChart from './OECDChart';
/**
* A BoxPlot component
* @example <caption>Browser usage:</caption>
* const BoxPlotExample = new OECDCharts.BoxPlot({
* container: '#BoxPlotExample',
* title: 'Box Plot',
* extent: [350, 650],
* step: 50,
* renderInfoButton: true,
* data: [
* {
* values: [480, 500, 530],
* colors: ['#fddd5d', '#C7754E', '#900c3f'],
* labelLeft: {
* text: 'male low'
* },
* labelRight: {
* text: 'male top'
* }
* },
* {
* values: [400, 520, 550],
* colors: ['#aad356', '#61B77F', '#189aa8']
* }
* ]
* });
* @example <caption>ES6 modules usage:</caption>
* import { BoxPlot } from 'oecd-simple-charts';
* import 'oecd-simple-charts/build/oecd-simple-charts.css'
*
* const boxPlot = new BoxPlot({ chartOptions });
*
* @constructor
* @param {object} options - The options object for the Box Plot
* @param {string} options.container - The DOM element to use as container
* @param {string} options.title - The title to display
* @param {array} options.extent - The min and max value for generating the x-axis
* @param {number} options.step - Indicates the stepsize for the x-axis ticks
* @param {string} options.legend - HTML code for the legend
* @param {bool} [options.renderInfoButton = false] - The info-icon for the tooltip, renders after the title
* @param {int} [options.fontSize = 12] - The font-size for the labels in px
* @param {int} [options.markerHeight = 30] - The height of the marker in px
* @param {int} [options.markerHeight = 10] - The width of the marker in px
* @param {int} [options.radius = 10] -The radius for the pearl in px
* @param {array} options.data - The data as array
* @param {array} options.data.values - The values to display
* @param {array} options.data.colors - The colors for the elements
* @param {object} options.data.labelLeft - (optional) Label for the left marker
* @param {string} options.data.labelLeft.label - (optional) Text for the left marker
* @param {string} options.data.labelLeft.icon - (optional) Path to icon for the left marker
* @param {object} options.data.labelRight - (optional) Label for the right marker
* @param {string} options.data.labelRight.label - (optional) Text for the right marker
* @param {string} options.data.labelRight.icon - (optional) Path to icon for the right marker
*/
class BoxPlot extends OECDChart {
constructor(options = {}) {
super();
this.defaultOptions = {
container: null,
extent: null,
data: [],
step: 50,
legend: null,
innerMarginTop: 10,
innerMarginBottom: 10,
innerMarginLeft: 5,
innerMarginRight: 5,
markerHeight: 30,
markerWidth: 10,
radius: 10,
fontSize: 12
};
this.init(options);
}
render(options) {
this.update(this.options.data);
}
/**
* @memberof BoxPlot
* @param {array} data - an array containing objects with the new data
* @example
* BoxPlotExample.update([
* {
* values: [400, 550, 580],
* colors: ['#fddd5d', '#C7754E', '#900c3f'],
* labelLeft: {
* text: 'new label left',
* },
* labelRight: {
* text: 'new label right',
* }
* },
* {
* values: [400, 520, 570],
* colors: ['#aad356', '#61B77F', '#189aa8']
* }
* ]);
*/
update(_data) {
this.options.data = _data;
const data = BoxPlot.parseData(_data);
BoxPlot.validateData(data);
this.getChart(data);
const markerGroup = this.getMarkerGroups(data);
this.getMarkers(markerGroup);
this.getLabels(markerGroup);
}
getChart(data) {
const {
container,
extent,
step,
legend,
markerHeight,
innerMarginTop,
innerMarginBottom,
innerMarginLeft,
innerMarginRight
} = this.options;
const d3Container = d3Select(container);
const dimensions = d3Container.node().getBoundingClientRect();
const outerWidth = dimensions.width;
const innerWidth = outerWidth - innerMarginLeft - innerMarginRight;
this.removeSelections(['.boxplot__svg', '.boxplot__legend']);
this.x = d3ScaleLinear()
.range([0, innerWidth - innerMarginRight - innerMarginLeft])
.domain(extent);
const height = ((markerHeight + 20) * data.length) + innerMarginTop + innerMarginBottom;
d3Container
.append('div')
.attr('class', 'boxplot__legend')
.html(legend);
this.svg = d3Container
.classed('OECDCharts__BoxPlot', true)
.append('svg')
.classed('boxplot__svg', true)
.attr('width', innerWidth)
.attr('height', height)
.append('g')
.attr('transform', `translate(${innerMarginLeft}, 0)`);
const ticks = (extent[1] - extent[0]) / step;
const axis = d3AxisBottom(this.x).ticks(ticks).tickSize(-height);
this.svg.append('g')
.classed('boxplot__axis', true)
.attr('transform', `translate(${innerMarginLeft}, ${height - innerMarginTop - innerMarginBottom})`)
.call(axis);
}
getMarkerGroups(_data) {
const markerGroup = this.svg.selectAll('.boxplot__marker-group')
.data(_data, d => JSON.stringify(d));
markerGroup.exit().remove();
return markerGroup.enter()
.append('g')
.classed('boxplot__marker-group', true)
.attr('transform', (d, i) => `translate(0, ${((this.options.markerHeight + 20) * i) + 1})`);
}
getMarkers(markerGroup) {
markerGroup.append('line')
.classed('marker-group__line', true)
.attr('x1', d => this.x(d.values[0]) + 5)
.attr('x2', d => this.x(d.values[2]) + 5)
.attr('y1', this.options.markerHeight / 2)
.attr('y2', this.options.markerHeight / 2)
.style('stroke', d => d.colors[1]);
markerGroup.selectAll('.marker-group__rect')
.data(d => [0, 2].map(i => ({ value: d.values[i], color: d.colors[i] })))
.enter()
.append('rect')
.classed('marker-group__rect', true)
.attr('height', this.options.markerHeight)
.attr('width', this.options.markerWidth)
.attr('x', d => (this.x(d.value) + 5) - (this.options.markerWidth / 2))
.style('fill', d => d.color);
markerGroup.selectAll('.marker-group__circle')
.data(d => [d])
.enter()
.append('circle')
.classed('marker-group__circle', true)
.attr('r', this.options.radius)
.attr('cx', d => this.x(d.values[1]) + 5)
.attr('cy', this.options.markerHeight / 2)
.style('fill', d => d.colors[1]);
}
getLabels(markerGroup) {
this.getLabel(markerGroup, 'left');
this.getLabel(markerGroup, 'right');
}
getLabel(markerGroup, pos) {
const left = pos === 'left';
const label = markerGroup
.filter(d => (left ? d.labelLeft : d.labelRight))
.selectAll(`.marker-group__label--${pos}`)
.data(d => [d])
.enter()
.append('g')
.classed(`marker-group__label--${pos}`, true);
// add label text
label
.filter(d => (left ? d.labelLeft.text : d.labelRight.text))
.selectAll(`.marker-group__label--text-${pos}`)
.data((d) => {
const { text, icon } = (left ? d.labelLeft : d.labelRight);
return [{ text, icon, value: (left ? d.values[0] : d.values[2]) }];
})
.enter()
.append('text')
.classed(`marker-group__label--text-${pos}`, true)
.attr('y', (this.options.markerHeight / 2) + (this.options.fontSize / 4))
.attr('x', (d) => {
const offset = d.icon ? 30 : 5;
return left ? this.x(d.value) - (offset) : this.x(d.value) + offset + 10;
})
.text(d => d.text)
.attr('text-anchor', left ? 'end' : 'start')
.attr('font-size', this.options.fontSize);
// add label icon
label
.filter(d => (left ? d.labelLeft.icon : d.labelRight.icon))
.selectAll(`.marker-group__label--icon-${pos}`)
.data((d) => {
const { icon } = (left ? d.labelLeft : d.labelRight);
return [{ icon, value: (left ? d.values[0] : d.values[2]) }];
})
.enter()
.append('svg:image')
.classed(`marker-group__label--icon-${pos}`, true)
.attr('y', (this.options.markerHeight / 2) - 10)
.attr('x', d => (left ? this.x(d.value) - 25 : this.x(d.value) + 15))
.attr('xlink:href', d => d.icon)
.attr('width', 20)
.attr('height', 20);
BoxPlot.arrangeLabels(markerGroup, this.svg);
}
static arrangeLabels(container, svgContainer) {
const svgBB = svgContainer.node().getBoundingClientRect();
container.selectAll('.marker-group__label--left')
.each((node, i, nodes) => {
const labelNode = nodes[i];
const bb = labelNode.getBoundingClientRect();
if (bb.left <= svgBB.left) {
d3Select(labelNode)
.attr('transform', `translate(${bb.width + 20}, ${bb.height / 1.1})`);
}
});
container.selectAll('.marker-group__label--right')
.each((node, i, nodes) => {
const labelNode = nodes[i];
const bb = labelNode.getBoundingClientRect();
if (bb.right >= svgBB.right) {
d3Select(labelNode)
.attr('transform', `translate(-${bb.width + 20}, ${bb.height / 1.1})`);
}
});
}
static parseData(_data) {
return _data.map((d) => {
d.colors = d.colors || ['#f0f0f0', '#555', '#000'];
return d;
});
}
static validateData(_data) {
const invalidData = _data.filter(
d => (d.values && d.values.length !== 3)
);
if (invalidData.length) {
throw Error('invalid data: values needs three items.');
}
}
}
export default BoxPlot;