Source: charts/bar-chart.js

import {select} from 'd3-selection';

import {StackMixin} from '../base/stack-mixin';
import {transition} from '../core/core';
import {constants} from '../core/constants';
import {logger} from '../core/logger';
import {pluck, utils} from '../core/utils';
import {d3compat} from '../core/config';

const MIN_BAR_WIDTH = 1;
const DEFAULT_GAP_BETWEEN_BARS = 2;
const LABEL_PADDING = 3;

/**
 * Concrete bar chart/histogram implementation.
 *
 * Examples:
 * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}
 * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats}
 * @mixes StackMixin
 */
export class BarChart extends StackMixin {
    /**
     * Create a Bar Chart
     * @example
     * // create a bar chart under #chart-container1 element using the default global chart group
     * var chart1 = new BarChart('#chart-container1');
     * // create a bar chart under #chart-container2 element using chart group A
     * var chart2 = new BarChart('#chart-container2', 'chartGroupA');
     * // create a sub-chart under a composite parent chart
     * var chart3 = new BarChart(compositeChart);
     * @param {String|node|d3.selection|CompositeChart} 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.  If the bar
     * chart is a sub-chart in a {@link CompositeChart Composite Chart} then pass in the parent
     * composite chart instance instead.
     * @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._gap = DEFAULT_GAP_BETWEEN_BARS;
        this._centerBar = false;
        this._alwaysUseRounding = false;

        this._barWidth = undefined;

        this.label(d => utils.printSingleValue(d.y0 + d.y), false);

        this.anchor(parent, chartGroup);
    }

    /**
     * Get or set the outer padding on an ordinal bar chart. This setting has no effect on non-ordinal charts.
     * Will pad the width by `padding * barWidth` on each side of the chart.
     * @param {Number} [padding=0.5]
     * @returns {Number|BarChart}
     */
    outerPadding (padding) {
        if (!arguments.length) {
            return this._outerRangeBandPadding();
        }
        return this._outerRangeBandPadding(padding);
    }

    rescale () {
        super.rescale();
        this._barWidth = undefined;
        return this;
    }

    render () {
        if (this.round() && this._centerBar && !this._alwaysUseRounding) {
            logger.warn('By default, brush rounding is disabled if bars are centered. ' +
                'See dc.js bar chart API documentation for details.');
        }

        return super.render();
    }

    plotData () {
        let layers = this.chartBodyG().selectAll('g.stack')
            .data(this.data());

        this._calculateBarWidth();

        layers = layers
            .enter()
            .append('g')
            .attr('class', (d, i) => `stack _${i}`)
            .merge(layers);

        const last = layers.size() - 1;
        {
            const chart = this;
            layers.each(function (d, i) {
                const layer = select(this);

                chart._renderBars(layer, i, d);

                if (chart.renderLabel() && last === i) {
                    chart._renderLabels(layer, i, d);
                }
            });
        }
    }

    _barHeight (d) {
        return utils.safeNumber(Math.abs(this.y()(d.y + d.y0) - this.y()(d.y0)));
    }

    _labelXPos (d) {
        let x = this.x()(d.x);
        if (!this._centerBar) {
            x += this._barWidth / 2;
        }
        if (this.isOrdinal() && this._gap !== undefined) {
            x += this._gap / 2;
        }
        return utils.safeNumber(x);
    }

    _labelYPos (d) {
        let y = this.y()(d.y + d.y0);

        if (d.y < 0) {
            y -= this._barHeight(d);
        }

        return utils.safeNumber(y - LABEL_PADDING);
    }

    _renderLabels (layer, layerIndex, data) {
        const labels = layer.selectAll('text.barLabel')
            .data(data.values, pluck('x'));

        const labelsEnterUpdate = labels
            .enter()
            .append('text')
            .attr('class', 'barLabel')
            .attr('text-anchor', 'middle')
            .attr('x', d => this._labelXPos(d))
            .attr('y', d => this._labelYPos(d))
            .merge(labels);

        if (this.isOrdinal()) {
            labelsEnterUpdate.on('click', d3compat.eventHandler(d => this.onClick(d)));
            labelsEnterUpdate.attr('cursor', 'pointer');
        }

        transition(labelsEnterUpdate, this.transitionDuration(), this.transitionDelay())
            .attr('x', d => this._labelXPos(d))
            .attr('y', d => this._labelYPos(d))
            .text(d => this.label()(d));

        transition(labels.exit(), this.transitionDuration(), this.transitionDelay())
            .attr('height', 0)
            .remove();
    }

    _barXPos (d) {
        let x = this.x()(d.x);
        if (this._centerBar) {
            x -= this._barWidth / 2;
        }
        if (this.isOrdinal() && this._gap !== undefined) {
            x += this._gap / 2;
        }
        return utils.safeNumber(x);
    }

    _renderBars (layer, layerIndex, data) {
        const bars = layer.selectAll('rect.bar')
            .data(data.values, pluck('x'));

        const enter = bars.enter()
            .append('rect')
            .attr('class', 'bar')
            .classed('dc-tabbable', this._keyboardAccessible)
            .attr('fill', pluck('data', this.getColor))
            .attr('x', d => this._barXPos(d))
            .attr('y', this.yAxisHeight())
            .attr('height', 0);

        const barsEnterUpdate = enter.merge(bars);

        if (this.renderTitle()) {
            enter.append('title').text(pluck('data', this.title(data.name)));
        }

        if (this.isOrdinal()) {
            barsEnterUpdate.on('click', d3compat.eventHandler(d => this.onClick(d)));
        }

        if (this._keyboardAccessible) {
            this._makeKeyboardAccessible(this.onClick);
        }

        transition(barsEnterUpdate, this.transitionDuration(), this.transitionDelay())
            .attr('x', d => this._barXPos(d))
            .attr('y', d => {
                let y = this.y()(d.y + d.y0);

                if (d.y < 0) {
                    y -= this._barHeight(d);
                }

                return utils.safeNumber(y);
            })
            .attr('width', this._barWidth)
            .attr('height', d => this._barHeight(d))
            .attr('fill', pluck('data', this.getColor))
            .select('title').text(pluck('data', this.title(data.name)));

        transition(bars.exit(), this.transitionDuration(), this.transitionDelay())
            .attr('x', d => this.x()(d.x))
            .attr('width', this._barWidth * 0.9)
            .remove();
    }

    _calculateBarWidth () {
        if (this._barWidth === undefined) {
            const numberOfBars = this.xUnitCount();

            // please can't we always use rangeBands for bar charts?
            if (this.isOrdinal() && this._gap === undefined) {
                this._barWidth = Math.floor(this.x().bandwidth());
            } else if (this._gap) {
                this._barWidth = Math.floor((this.xAxisLength() - (numberOfBars - 1) * this._gap) / numberOfBars);
            } else {
                this._barWidth = Math.floor(this.xAxisLength() / (1 + this.barPadding()) / numberOfBars);
            }

            if (this._barWidth === Infinity || isNaN(this._barWidth) || this._barWidth < MIN_BAR_WIDTH) {
                this._barWidth = MIN_BAR_WIDTH;
            }
        }
    }

    fadeDeselectedArea (brushSelection) {
        const bars = this.chartBodyG().selectAll('rect.bar');

        if (this.isOrdinal()) {
            if (this.hasFilter()) {
                bars.classed(constants.SELECTED_CLASS, d => this.hasFilter(d.x));
                bars.classed(constants.DESELECTED_CLASS, d => !this.hasFilter(d.x));
            } else {
                bars.classed(constants.SELECTED_CLASS, false);
                bars.classed(constants.DESELECTED_CLASS, false);
            }
        } else if (this.brushOn() || this.parentBrushOn()) {
            if (!this.brushIsEmpty(brushSelection)) {
                const start = brushSelection[0];
                const end = brushSelection[1];

                bars.classed(constants.DESELECTED_CLASS, d => d.x < start || d.x >= end);
            } else {
                bars.classed(constants.DESELECTED_CLASS, false);
            }
        }
    }

    /**
     * Whether the bar chart will render each bar centered around the data position on the x-axis.
     * @param {Boolean} [centerBar=false]
     * @returns {Boolean|BarChart}
     */
    centerBar (centerBar) {
        if (!arguments.length) {
            return this._centerBar;
        }
        this._centerBar = centerBar;
        return this;
    }

    onClick (d) {
        super.onClick(d.data);
    }

    /**
     * Get or set the spacing between bars as a fraction of bar size. Valid values are between 0-1.
     * Setting this value will also remove any previously set {@link BarChart#gap gap}. 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.
     * @param {Number} [barPadding=0]
     * @returns {Number|BarChart}
     */
    barPadding (barPadding) {
        if (!arguments.length) {
            return this._rangeBandPadding();
        }
        this._rangeBandPadding(barPadding);
        this._gap = undefined;
        return this;
    }

    _useOuterPadding () {
        return this._gap === undefined;
    }

    /**
     * Manually set fixed gap (in px) between bars instead of relying on the default auto-generated
     * gap.  By default the bar chart implementation will calculate and set the gap automatically
     * based on the number of data points and the length of the x axis.
     * @param {Number} [gap=2]
     * @returns {Number|BarChart}
     */
    gap (gap) {
        if (!arguments.length) {
            return this._gap;
        }
        this._gap = gap;
        return this;
    }

    extendBrush (brushSelection) {
        if (brushSelection && this.round() && (!this._centerBar || this._alwaysUseRounding)) {
            brushSelection[0] = this.round()(brushSelection[0]);
            brushSelection[1] = this.round()(brushSelection[1]);
        }
        return brushSelection;
    }

    /**
     * Set or get whether rounding is enabled when bars are centered. If false, using
     * rounding with centered bars will result in a warning and rounding will be ignored.  This flag
     * has no effect if bars are not {@link BarChart#centerBar centered}.
     * When using standard d3.js rounding methods, the brush often doesn't align correctly with
     * centered bars since the bars are offset.  The rounding function must add an offset to
     * compensate, such as in the following example.
     * @example
     * chart.round(function(n) { return Math.floor(n) + 0.5; });
     * @param {Boolean} [alwaysUseRounding=false]
     * @returns {Boolean|BarChart}
     */
    alwaysUseRounding (alwaysUseRounding) {
        if (!arguments.length) {
            return this._alwaysUseRounding;
        }
        this._alwaysUseRounding = alwaysUseRounding;
        return this;
    }

    legendHighlight (d) {
        const colorFilter = (color, inv) => function () {
            const item = select(this);
            const match = item.attr('fill') === color;
            return inv ? !match : match;
        };

        if (!this.isLegendableHidden(d)) {
            this.g().selectAll('rect.bar')
                .classed('highlight', colorFilter(d.color))
                .classed('fadeout', colorFilter(d.color, true));
        }
    }

    legendReset () {
        this.g().selectAll('rect.bar')
            .classed('highlight', false)
            .classed('fadeout', false);
    }

    xAxisMax () {
        let max = super.xAxisMax();
        if ('resolution' in this.xUnits()) {
            const res = this.xUnits().resolution;
            max += res;
        }
        return max;
    }
}

export const barChart = (parent, chartGroup) => new BarChart(parent, chartGroup);