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