import {scaleBand} from 'd3-scale'; import {select} from 'd3-selection'; import {min, max} from 'd3-array'; import {d3Box} from '../base/d3.box' import {CoordinateGridMixin} from '../base/coordinate-grid-mixin'; import {transition} from '../core/core'; import {units} from '../core/units'; import {utils} from '../core/utils'; import {d3compat} from '../core/config'; // Returns a function to compute the interquartile range. function defaultWhiskersIQR (k) { return d => { const q1 = d.quartiles[0]; const q3 = d.quartiles[2]; const iqr = (q3 - q1) * k; let i = -1; let j = d.length; do { ++i; } while (d[i] < q1 - iqr); do { --j; } while (d[j] > q3 + iqr); return [i, j]; }; } /** * A box plot is a chart that depicts numerical data via their quartile ranges. * * Examples: * - {@link http://dc-js.github.io/dc.js/examples/boxplot-basic.html Boxplot Basic example} * - {@link http://dc-js.github.io/dc.js/examples/boxplot-enhanced.html Boxplot Enhanced example} * - {@link http://dc-js.github.io/dc.js/examples/boxplot-render-data.html Boxplot Render Data example} * - {@link http://dc-js.github.io/dc.js/examples/boxplot-time.html Boxplot time example} * @mixes CoordinateGridMixin */ export class BoxPlot extends CoordinateGridMixin { /** * Create a Box Plot. * * @example * // create a box plot under #chart-container1 element using the default global chart group * var boxPlot1 = new BoxPlot('#chart-container1'); * // create a box plot under #chart-container2 element using chart group A * var boxPlot2 = new BoxPlot('#chart-container2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. */ constructor (parent, chartGroup) { super(); this._whiskerIqrFactor = 1.5; this._whiskersIqr = defaultWhiskersIQR; this._whiskers = this._whiskersIqr(this._whiskerIqrFactor); this._box = d3Box(); this._tickFormat = null; this._renderDataPoints = false; this._dataOpacity = 0.3; this._dataWidthPortion = 0.8; this._showOutliers = true; this._boldOutlier = false; // Used in yAxisMin and yAxisMax to add padding in pixel coordinates // so the min and max data points/whiskers are within the chart this._yRangePadding = 8; this._boxWidth = (innerChartWidth, xUnits) => { if (this.isOrdinal()) { return this.x().bandwidth(); } else { return innerChartWidth / (1 + this.boxPadding()) / xUnits; } }; // default to ordinal this.x(scaleBand()); this.xUnits(units.ordinal); // valueAccessor should return an array of values that can be coerced into numbers // or if data is overloaded for a static array of arrays, it should be `Number`. // Empty arrays are not included. this.data(group => group.all().map(d => { d.map = accessor => accessor.call(d, d); return d; }).filter(d => { const values = this.valueAccessor()(d); return values.length !== 0; })); this.boxPadding(0.8); this.outerPadding(0.5); this.anchor(parent, chartGroup); } /** * Get or set the spacing between boxes as a fraction of box size. Valid values are within 0-1. * See the {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3 docs} * for a visual description of how the padding is applied. * @see {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3.scaleBand} * @param {Number} [padding=0.8] * @returns {Number|BoxPlot} */ boxPadding (padding) { if (!arguments.length) { return this._rangeBandPadding(); } return this._rangeBandPadding(padding); } /** * Get or set the outer padding on an ordinal box chart. This setting has no effect on non-ordinal charts * or on charts with a custom {@link BoxPlot#boxWidth .boxWidth}. Will pad the width by * `padding * barWidth` on each side of the chart. * @param {Number} [padding=0.5] * @returns {Number|BoxPlot} */ outerPadding (padding) { if (!arguments.length) { return this._outerRangeBandPadding(); } return this._outerRangeBandPadding(padding); } /** * Get or set the numerical width of the boxplot box. The width may also be a function taking as * parameters the chart width excluding the right and left margins, as well as the number of x * units. * @example * // Using numerical parameter * chart.boxWidth(10); * // Using function * chart.boxWidth((innerChartWidth, xUnits) { ... }); * @param {Number|Function} [boxWidth=0.5] * @returns {Number|Function|BoxPlot} */ boxWidth (boxWidth) { if (!arguments.length) { return this._boxWidth; } this._boxWidth = typeof boxWidth === 'function' ? boxWidth : utils.constant(boxWidth); return this; } _boxTransform (d, i) { const xOffset = this.x()(this.keyAccessor()(d, i)); return `translate(${xOffset}, 0)`; } _preprocessData () { if (this.elasticX()) { this.x().domain([]); } } plotData () { this._calculatedBoxWidth = this._boxWidth(this.effectiveWidth(), this.xUnitCount()); this._box.whiskers(this._whiskers) .width(this._calculatedBoxWidth) .height(this.effectiveHeight()) .value(this.valueAccessor()) .domain(this.y().domain()) .duration(this.transitionDuration()) .tickFormat(this._tickFormat) .renderDataPoints(this._renderDataPoints) .dataOpacity(this._dataOpacity) .dataWidthPortion(this._dataWidthPortion) .renderTitle(this.renderTitle()) .showOutliers(this._showOutliers) .boldOutlier(this._boldOutlier); const boxesG = this.chartBodyG().selectAll('g.box').data(this.data(), this.keyAccessor()); const boxesGEnterUpdate = this._renderBoxes(boxesG); this._updateBoxes(boxesGEnterUpdate); this._removeBoxes(boxesG); this.fadeDeselectedArea(this.filter()); } _renderBoxes (boxesG) { const boxesGEnter = boxesG.enter().append('g'); boxesGEnter .attr('class', 'box') .classed('dc-tabbable', this._keyboardAccessible) .attr('transform', (d, i) => this._boxTransform(d, i)) .call(this._box) .on('click', d3compat.eventHandler(d => { this.filter(this.keyAccessor()(d)); this.redrawGroup(); })) .selectAll('circle') .classed('dc-tabbable', this._keyboardAccessible); if (this._keyboardAccessible) { this._makeKeyboardAccessible(this.onClick); } return boxesGEnter.merge(boxesG); } _updateBoxes (boxesG) { const chart = this; transition(boxesG, this.transitionDuration(), this.transitionDelay()) .attr('transform', (d, i) => this._boxTransform(d, i)) .call(this._box) .each(function (d) { const color = chart.getColor(d, 0); select(this).select('rect.box').attr('fill', color); select(this).selectAll('circle.data').attr('fill', color); }); } _removeBoxes (boxesG) { boxesG.exit().remove().call(this._box); } _minDataValue () { return min(this.data(), e => min(this.valueAccessor()(e))); } _maxDataValue () { return max(this.data(), e => max(this.valueAccessor()(e))); } _yAxisRangeRatio () { return ((this._maxDataValue() - this._minDataValue()) / this.effectiveHeight()); } onClick (d) { this.filter(this.keyAccessor()(d)); this.redrawGroup(); } fadeDeselectedArea (brushSelection) { const chart = this; if (this.hasFilter()) { if (this.isOrdinal()) { this.g().selectAll('g.box').each(function (d) { if (chart.isSelectedNode(d)) { chart.highlightSelected(this); } else { chart.fadeDeselected(this); } }); } else { if (!(this.brushOn() || this.parentBrushOn())) { return; } const start = brushSelection[0]; const end = brushSelection[1]; this.g().selectAll('g.box').each(function (d) { const key = chart.keyAccessor()(d); if (key < start || key >= end) { chart.fadeDeselected(this); } else { chart.highlightSelected(this); } }); } } else { this.g().selectAll('g.box').each(function () { chart.resetHighlight(this); }); } } isSelectedNode (d) { return this.hasFilter(this.keyAccessor()(d)); } yAxisMin () { const padding = this._yRangePadding * this._yAxisRangeRatio(); return utils.subtract(this._minDataValue() - padding, this.yAxisPadding()); } yAxisMax () { const padding = this._yRangePadding * this._yAxisRangeRatio(); return utils.add(this._maxDataValue() + padding, this.yAxisPadding()); } /** * Get or set the numerical format of the boxplot median, whiskers and quartile labels. Defaults * to integer formatting. * @example * // format ticks to 2 decimal places * chart.tickFormat(d3.format('.2f')); * @param {Function} [tickFormat] * @returns {Number|Function|BoxPlot} */ tickFormat (tickFormat) { if (!arguments.length) { return this._tickFormat; } this._tickFormat = tickFormat; return this; } /** * Get or set the amount of padding to add, in pixel coordinates, to the top and * bottom of the chart to accommodate box/whisker labels. * @example * // allow more space for a bigger whisker font * chart.yRangePadding(12); * @param {Function} [yRangePadding = 8] * @returns {Number|Function|BoxPlot} */ yRangePadding (yRangePadding) { if (!arguments.length) { return this._yRangePadding; } this._yRangePadding = yRangePadding; return this; } /** * Get or set whether individual data points will be rendered. * @example * // Enable rendering of individual data points * chart.renderDataPoints(true); * @param {Boolean} [show=false] * @returns {Boolean|BoxPlot} */ renderDataPoints (show) { if (!arguments.length) { return this._renderDataPoints; } this._renderDataPoints = show; return this; } /** * Get or set the opacity when rendering data. * @example * // If individual data points are rendered increase the opacity. * chart.dataOpacity(0.7); * @param {Number} [opacity=0.3] * @returns {Number|BoxPlot} */ dataOpacity (opacity) { if (!arguments.length) { return this._dataOpacity; } this._dataOpacity = opacity; return this; } /** * Get or set the portion of the width of the box to show data points. * @example * // If individual data points are rendered increase the data box. * chart.dataWidthPortion(0.9); * @param {Number} [percentage=0.8] * @returns {Number|BoxPlot} */ dataWidthPortion (percentage) { if (!arguments.length) { return this._dataWidthPortion; } this._dataWidthPortion = percentage; return this; } /** * Get or set whether outliers will be rendered. * @example * // Disable rendering of outliers * chart.showOutliers(false); * @param {Boolean} [show=true] * @returns {Boolean|BoxPlot} */ showOutliers (show) { if (!arguments.length) { return this._showOutliers; } this._showOutliers = show; return this; } /** * Get or set whether outliers will be drawn bold. * @example * // If outliers are rendered display as bold * chart.boldOutlier(true); * @param {Boolean} [show=false] * @returns {Boolean|BoxPlot} */ boldOutlier (show) { if (!arguments.length) { return this._boldOutlier; } this._boldOutlier = show; return this; } } export const boxPlot = (parent, chartGroup) => new BoxPlot(parent, chartGroup);