Source: charts/box-plot.js

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);