Source: charts/composite-chart.js

import {min, max} from 'd3-array';
import {scaleLinear} from 'd3-scale';
import {axisRight} from 'd3-axis';

import {utils} from '../core/utils';
import {CoordinateGridMixin} from '../base/coordinate-grid-mixin';

const SUB_CHART_CLASS = 'sub';
const DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING = 12;

/**
 * Composite charts are a special kind of chart that render multiple charts on the same Coordinate
 * Grid. You can overlay (compose) different bar/line/area charts in a single composite chart to
 * achieve some quite flexible charting effects.
 * @mixes CoordinateGridMixin
 */
export class CompositeChart extends CoordinateGridMixin {
    /**
     * Create a Composite Chart.
     * @example
     * // create a composite chart under #chart-container1 element using the default global chart group
     * var compositeChart1 = new CompositeChart('#chart-container1');
     * // create a composite chart under #chart-container2 element using chart group A
     * var compositeChart2 = new CompositeChart('#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._children = [];

        this._childOptions = {};

        this._shareColors = false;
        this._shareTitle = true;
        this._alignYAxes = false;

        this._rightYAxis = axisRight();
        this._rightYAxisLabel = 0;
        this._rightYAxisLabelPadding = DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING;
        this._rightY = undefined;
        this._rightAxisGridLines = false;

        this._mandatoryAttributes([]);
        this.transitionDuration(500);
        this.transitionDelay(0);

        this.on('filtered.dcjs-composite-chart', chart => {
            // Propagate the filters onto the children
            // Notice that on children the call is .replaceFilter and not .filter
            //   the reason is that _chart.filter() returns the entire current set of filters not just the last added one
            for (let i = 0; i < this._children.length; ++i) {
                this._children[i].replaceFilter(this.filter());
            }
        });

        this.anchor(parent, chartGroup);
    }

    _generateG () {
        const g = super._generateG();

        for (let i = 0; i < this._children.length; ++i) {
            const child = this._children[i];

            this._generateChildG(child, i);

            if (!child.dimension()) {
                child.dimension(this.dimension());
            }
            if (!child.group()) {
                child.group(this.group());
            }

            child.chartGroup(this.chartGroup());
            child.svg(this.svg());
            child.xUnits(this.xUnits());
            child.transitionDuration(this.transitionDuration(), this.transitionDelay());
            child.parentBrushOn(this.brushOn());
            child.brushOn(false);
            child.renderTitle(this.renderTitle());
            child.elasticX(this.elasticX());
        }

        return g;
    }

    rescale () {
        super.rescale();

        this._children.forEach(child => {
            child.rescale();
        });

        return this;
    }

    resizing (resizing) {
        if (!arguments.length) {
            return super.resizing();
        }
        super.resizing(resizing);

        this._children.forEach(child => {
            child.resizing(resizing);
        });

        return this;
    }

    _prepareYAxis () {
        const left = (this._leftYAxisChildren().length !== 0);
        const right = (this._rightYAxisChildren().length !== 0);
        const ranges = this._calculateYAxisRanges(left, right);

        if (left) {
            this._prepareLeftYAxis(ranges);
        }
        if (right) {
            this._prepareRightYAxis(ranges);
        }

        if (this._leftYAxisChildren().length > 0 && !this._rightAxisGridLines) {
            this._renderHorizontalGridLinesForAxis(this.g(), this.y(), this.yAxis());
        } else if (this._rightYAxisChildren().length > 0) {
            this._renderHorizontalGridLinesForAxis(this.g(), this._rightY, this._rightYAxis);
        }
    }

    renderYAxis () {
        if (this._leftYAxisChildren().length !== 0) {
            this.renderYAxisAt('y', this.yAxis(), this.margins().left);
            this.renderYAxisLabel('y', this.yAxisLabel(), -90);
        }

        if (this._rightYAxisChildren().length !== 0) {
            this.renderYAxisAt('yr', this.rightYAxis(), this.width() - this.margins().right);
            this.renderYAxisLabel('yr', this.rightYAxisLabel(), 90, this.width() - this._rightYAxisLabelPadding);
        }
    }

    _calculateYAxisRanges (left, right) {
        let lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax;
        let ranges;

        if (left) {
            lyAxisMin = this._yAxisMin();
            lyAxisMax = this._yAxisMax();
        }

        if (right) {
            ryAxisMin = this._rightYAxisMin();
            ryAxisMax = this._rightYAxisMax();
        }

        if (this.alignYAxes() && left && right) {
            ranges = this._alignYAxisRanges(lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax);
        }

        return ranges || {
            lyAxisMin: lyAxisMin,
            lyAxisMax: lyAxisMax,
            ryAxisMin: ryAxisMin,
            ryAxisMax: ryAxisMax
        };
    }

    _alignYAxisRanges (lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax) {
        // since the two series will share a zero, each Y is just a multiple
        // of the other. and the ratio should be the ratio of the ranges of the
        // input data, so that they come out the same height. so we just min/max

        // note: both ranges already include zero due to the stack mixin (#667)
        // if #667 changes, we can reconsider whether we want data height or
        // height from zero to be equal. and it will be possible for the axes
        // to be aligned but not visible.
        const extentRatio = (ryAxisMax - ryAxisMin) / (lyAxisMax - lyAxisMin);

        return {
            lyAxisMin: Math.min(lyAxisMin, ryAxisMin / extentRatio),
            lyAxisMax: Math.max(lyAxisMax, ryAxisMax / extentRatio),
            ryAxisMin: Math.min(ryAxisMin, lyAxisMin * extentRatio),
            ryAxisMax: Math.max(ryAxisMax, lyAxisMax * extentRatio)
        };
    }

    _prepareRightYAxis (ranges) {
        const needDomain = this.rightY() === undefined || this.elasticY(),
            needRange = needDomain || this.resizing();
        if (this.rightY() === undefined) {
            this.rightY(scaleLinear());
        }
        if (needDomain) {
            this.rightY().domain([ranges.ryAxisMin, ranges.ryAxisMax]);
        }
        if (needRange) {
            this.rightY().rangeRound([this.yAxisHeight(), 0]);
        }

        this.rightY().range([this.yAxisHeight(), 0]);
        this.rightYAxis(this.rightYAxis().scale(this.rightY()));

        // In D3v4 create a RightAxis
        // _chart.rightYAxis().orient('right');
    }

    _prepareLeftYAxis (ranges) {
        const needDomain = this.y() === undefined || this.elasticY(),
            needRange = needDomain || this.resizing();
        if (this.y() === undefined) {
            this.y(scaleLinear());
        }
        if (needDomain) {
            this.y().domain([ranges.lyAxisMin, ranges.lyAxisMax]);
        }
        if (needRange) {
            this.y().rangeRound([this.yAxisHeight(), 0]);
        }

        this.y().range([this.yAxisHeight(), 0]);
        this.yAxis(this.yAxis().scale(this.y()));

        // In D3v4 create a LeftAxis
        // _chart.yAxis().orient('left');
    }

    _generateChildG (child, i) {
        child._generateG(this.g());
        child.g().attr('class', `${SUB_CHART_CLASS} _${i}`);
    }

    plotData () {
        for (let i = 0; i < this._children.length; ++i) {
            const child = this._children[i];

            if (!child.g()) {
                this._generateChildG(child, i);
            }

            if (this._shareColors) {
                child.colors(this.colors());
            }

            child.x(this.x());

            child.xAxis(this.xAxis());

            if (child.useRightYAxis()) {
                child.y(this.rightY());
                child.yAxis(this.rightYAxis());
            } else {
                child.y(this.y());
                child.yAxis(this.yAxis());
            }

            child.plotData();

            child._activateRenderlets();
        }
    }

    /**
     * Get or set whether to draw gridlines from the right y axis.  Drawing from the left y axis is the
     * default behavior. This option is only respected when subcharts with both left and right y-axes
     * are present.
     * @param {Boolean} [useRightAxisGridLines=false]
     * @returns {Boolean|CompositeChart}
     */
    useRightAxisGridLines (useRightAxisGridLines) {
        if (!arguments) {
            return this._rightAxisGridLines;
        }

        this._rightAxisGridLines = useRightAxisGridLines;
        return this;
    }

    /**
     * Get or set chart-specific options for all child charts. This is equivalent to calling
     * {@link BaseMixin#options .options} on each child chart.
     * @param {Object} [childOptions]
     * @returns {Object|CompositeChart}
     */
    childOptions (childOptions) {
        if (!arguments.length) {
            return this._childOptions;
        }
        this._childOptions = childOptions;
        this._children.forEach(child => {
            child.options(this._childOptions);
        });
        return this;
    }

    fadeDeselectedArea (brushSelection) {
        if (this.brushOn()) {
            for (let i = 0; i < this._children.length; ++i) {
                const child = this._children[i];
                child.fadeDeselectedArea(brushSelection);
            }
        }
    }

    /**
     * Set or get the right y axis label.
     * @param {String} [rightYAxisLabel]
     * @param {Number} [padding]
     * @returns {String|CompositeChart}
     */
    rightYAxisLabel (rightYAxisLabel, padding) {
        if (!arguments.length) {
            return this._rightYAxisLabel;
        }
        this._rightYAxisLabel = rightYAxisLabel;
        this.margins().right -= this._rightYAxisLabelPadding;
        this._rightYAxisLabelPadding = (padding === undefined) ? DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING : padding;
        this.margins().right += this._rightYAxisLabelPadding;
        return this;
    }

    /**
     * Combine the given charts into one single composite coordinate grid chart.
     * @example
     * moveChart.compose([
     *     // when creating sub-chart you need to pass in the parent chart
     *     new LineChart(moveChart)
     *         .group(indexAvgByMonthGroup) // if group is missing then parent's group will be used
     *         .valueAccessor(function (d){return d.value.avg;})
     *         // most of the normal functions will continue to work in a composed chart
     *         .renderArea(true)
     *         .stack(monthlyMoveGroup, function (d){return d.value;})
     *         .title(function (d){
     *             var value = d.value.avg?d.value.avg:d.value;
     *             if(isNaN(value)) value = 0;
     *             return dateFormat(d.key) + '\n' + numberFormat(value);
     *         }),
     *     new BarChart(moveChart)
     *         .group(volumeByMonthGroup)
     *         .centerBar(true)
     * ]);
     * @param {Array<Chart>} [subChartArray]
     * @returns {CompositeChart}
     */
    compose (subChartArray) {
        this._children = subChartArray;
        this._children.forEach(child => {
            child.height(this.height());
            child.width(this.width());
            child.margins(this.margins());

            if (this._shareTitle) {
                child.title(this.title());
            }

            child.options(this._childOptions);
        });
        this.rescale();
        return this;
    }

    _setChildrenProperty (prop, value) {
        this._children.forEach(child => {
            child[prop](value);
        });
    }

    // properties passed through in compose()
    height (height) {
        if(!arguments.length) {
            return super.height();
        }
        super.height(height);
        this._setChildrenProperty('height', height);
        return this;
    }

    width (width) {
        if(!arguments.length) {
            return super.width();
        }
        super.width(width);
        this._setChildrenProperty('width', width);
        return this;
    }

    margins (margins) {
        if(!arguments.length) {
            return super.margins();
        }
        super.margins(margins);
        this._setChildrenProperty('margins', margins);
        return this;
    }

    /**
     * Returns the child charts which are composed into the composite chart.
     * @returns {Array<BaseMixin>}
     */
    children () {
        return this._children;
    }

    /**
     * Get or set color sharing for the chart. If set, the {@link ColorMixin#colors .colors()} value from this chart
     * will be shared with composed children. Additionally if the child chart implements
     * Stackable and has not set a custom .colorAccessor, then it will generate a color
     * specific to its order in the composition.
     * @param {Boolean} [shareColors=false]
     * @returns {Boolean|CompositeChart}
     */
    shareColors (shareColors) {
        if (!arguments.length) {
            return this._shareColors;
        }
        this._shareColors = shareColors;
        return this;
    }

    /**
     * Get or set title sharing for the chart. If set, the {@link BaseMixin#title .title()} value from
     * this chart will be shared with composed children.
     *
     * Note: currently you must call this before `compose` or the child will still get the parent's
     * `title` function!
     * @param {Boolean} [shareTitle=true]
     * @returns {Boolean|CompositeChart}
     */
    shareTitle (shareTitle) {
        if (!arguments.length) {
            return this._shareTitle;
        }
        this._shareTitle = shareTitle;
        return this;
    }

    /**
     * Get or set the y scale for the right axis. The right y scale is typically automatically
     * generated by the chart implementation.
     * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}
     * @param {d3.scale} [yScale]
     * @returns {d3.scale|CompositeChart}
     */
    rightY (yScale) {
        if (!arguments.length) {
            return this._rightY;
        }
        this._rightY = yScale;
        this.rescale();
        return this;
    }

    /**
     * Get or set alignment between left and right y axes. A line connecting '0' on both y axis
     * will be parallel to x axis. This only has effect when {@link CoordinateGridMixin#elasticY elasticY} is true.
     * @param {Boolean} [alignYAxes=false]
     * @returns {Chart}
     */
    alignYAxes (alignYAxes) {
        if (!arguments.length) {
            return this._alignYAxes;
        }
        this._alignYAxes = alignYAxes;
        this.rescale();
        return this;
    }

    _leftYAxisChildren () {
        return this._children.filter(child => !child.useRightYAxis());
    }

    _rightYAxisChildren () {
        return this._children.filter(child => child.useRightYAxis());
    }

    _getYAxisMin (charts) {
        return charts.map(c => c.yAxisMin());
    }

    _yAxisMin () {
        return min(this._getYAxisMin(this._leftYAxisChildren()));
    }

    _rightYAxisMin () {
        return min(this._getYAxisMin(this._rightYAxisChildren()));
    }

    _getYAxisMax (charts) {
        return charts.map(c => c.yAxisMax());
    }

    _yAxisMax () {
        return utils.add(max(this._getYAxisMax(this._leftYAxisChildren())), this.yAxisPadding());
    }

    _rightYAxisMax () {
        return utils.add(max(this._getYAxisMax(this._rightYAxisChildren())), this.yAxisPadding());
    }

    _getAllXAxisMinFromChildCharts () {
        return this._children.map(c => c.xAxisMin());
    }

    xAxisMin () {
        return utils.subtract(min(this._getAllXAxisMinFromChildCharts()), this.xAxisPadding(), this.xAxisPaddingUnit());
    }

    _getAllXAxisMaxFromChildCharts () {
        return this._children.map(c => c.xAxisMax());
    }

    xAxisMax () {
        return utils.add(max(this._getAllXAxisMaxFromChildCharts()), this.xAxisPadding(), this.xAxisPaddingUnit());
    }

    legendables () {
        return this._children.reduce((items, child) => {
            if (this._shareColors) {
                child.colors(this.colors());
            }
            items.push.apply(items, child.legendables());
            return items;
        }, []);
    }

    legendHighlight (d) {
        for (let j = 0; j < this._children.length; ++j) {
            const child = this._children[j];
            child.legendHighlight(d);
        }
    }

    legendReset (d) {
        for (let j = 0; j < this._children.length; ++j) {
            const child = this._children[j];
            child.legendReset(d);
        }
    }

    legendToggle () {
        console.log('composite should not be getting legendToggle itself');
    }

    /**
     * Set or get the right y axis used by the composite chart. This function is most useful when y
     * axis customization is required. The y axis in dc.js is an instance of a
     * [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight) therefore it supports any valid
     * d3 axis manipulation.
     *
     * **Caution**: The right y axis is usually generated internally by dc; resetting it may cause
     * unexpected results.  Note also that when used as a getter, this function is not chainable: it
     * returns the axis, not the chart,
     * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis
     * so attempting to call chart functions after calling `.yAxis()` will fail}.
     * @see {@link https://github.com/d3/d3-axis/blob/master/README.md#axisRight}
     * @example
     * // customize y axis tick format
     * chart.rightYAxis().tickFormat(function (v) {return v + '%';});
     * // customize y axis tick values
     * chart.rightYAxis().tickValues([0, 100, 200, 300]);
     * @param {d3.axisRight} [rightYAxis]
     * @returns {d3.axisRight|CompositeChart}
     */
    rightYAxis (rightYAxis) {
        if (!arguments.length) {
            return this._rightYAxis;
        }
        this._rightYAxis = rightYAxis;
        return this;
    }

    yAxisMin () {
        throw new Error('Not supported for this chart type');
    }

    yAxisMax () {
        throw new Error('Not supported for this chart type');
    }
}

export const compositeChart = (parent, chartGroup) => new CompositeChart(parent, chartGroup);