import {min, sum} from 'd3-array';
import {arc, pie} from 'd3-shape';
import {select} from 'd3-selection';
import {interpolate} from 'd3-interpolate';
import {CapMixin} from '../base/cap-mixin';
import {ColorMixin} from '../base/color-mixin';
import {BaseMixin} from '../base/base-mixin';
import {transition} from '../core/core';
import {d3compat} from '../core/config';
const DEFAULT_MIN_ANGLE_FOR_LABEL = 0.5;
/**
* The pie chart implementation is usually used to visualize a small categorical distribution. The pie
* chart uses keyAccessor to determine the slices, and valueAccessor to calculate the size of each
* slice relative to the sum of all values. Slices are ordered by {@link BaseMixin#ordering ordering}
* which defaults to sorting by key.
*
* Examples:
* - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}
* @mixes CapMixin
* @mixes ColorMixin
* @mixes BaseMixin
*/
export class PieChart extends CapMixin(ColorMixin(BaseMixin)) {
/**
* Create a Pie Chart
*
* @example
* // create a pie chart under #chart-container1 element using the default global chart group
* var chart1 = new PieChart('#chart-container1');
* // create a pie chart under #chart-container2 element using chart group A
* var chart2 = new PieChart('#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._sliceCssClass = 'pie-slice';
this._labelCssClass = 'pie-label';
this._sliceGroupCssClass = 'pie-slice-group';
this._labelGroupCssClass = 'pie-label-group';
this._emptyCssClass = 'empty-chart';
this._emptyTitle = 'empty';
this._radius = undefined;
this._givenRadius = undefined; // specified radius, if any
this._innerRadius = 0;
this._externalRadiusPadding = 0;
this._g = undefined;
this._cx = undefined;
this._cy = undefined;
this._minAngleForLabel = DEFAULT_MIN_ANGLE_FOR_LABEL;
this._externalLabelRadius = undefined;
this._drawPaths = false;
this.colorAccessor(d => this.cappedKeyAccessor(d));
this.title(d => `${this.cappedKeyAccessor(d)}: ${this.cappedValueAccessor(d)}`);
this.label(d => this.cappedKeyAccessor(d));
this.renderLabel(true);
this.transitionDuration(350);
this.transitionDelay(0);
this.anchor(parent, chartGroup);
}
/**
* Get or set the maximum number of slices the pie chart will generate. The top slices are determined by
* value from high to low. Other slices exceeding the cap will be rolled up into one single *Others* slice.
* @param {Number} [cap]
* @returns {Number|PieChart}
*/
slicesCap (cap) {
return this.cap(cap)
}
_doRender () {
this.resetSvg();
this._g = this.svg()
.append('g')
.attr('transform', `translate(${this.cx()},${this.cy()})`);
this._g.append('g').attr('class', this._sliceGroupCssClass);
this._g.append('g').attr('class', this._labelGroupCssClass);
this._drawChart();
return this;
}
_drawChart () {
// set radius from chart size if none given, or if given radius is too large
const maxRadius = min([this.width(), this.height()]) / 2;
this._radius = this._givenRadius && this._givenRadius < maxRadius ? this._givenRadius : maxRadius;
const arcs = this._buildArcs();
const pieLayout = this._pieLayout();
let pieData;
// if we have data...
if (sum(this.data(), d => this.cappedValueAccessor(d))) {
pieData = pieLayout(this.data());
this._g.classed(this._emptyCssClass, false);
} else {
// otherwise we'd be getting NaNs, so override
// note: abuse others for its ignoring the value accessor
pieData = pieLayout([{key: this._emptyTitle, value: 1, others: [this._emptyTitle]}]);
this._g.classed(this._emptyCssClass, true);
}
if (this._g) {
const slices = this._g.select(`g.${this._sliceGroupCssClass}`)
.selectAll(`g.${this._sliceCssClass}`)
.data(pieData);
const labels = this._g.select(`g.${this._labelGroupCssClass}`)
.selectAll(`text.${this._labelCssClass}`)
.data(pieData);
this._removeElements(slices, labels);
this._createElements(slices, labels, arcs, pieData);
this._updateElements(pieData, arcs);
this._highlightFilter();
transition(this._g, this.transitionDuration(), this.transitionDelay())
.attr('transform', `translate(${this.cx()},${this.cy()})`);
}
}
_createElements (slices, labels, arcs, pieData) {
const slicesEnter = this._createSliceNodes(slices);
this._createSlicePath(slicesEnter, arcs);
this._createTitles(slicesEnter);
this._createLabels(labels, pieData, arcs);
}
_createSliceNodes (slices) {
return slices
.enter()
.append('g')
.attr('class', (d, i) => `${this._sliceCssClass} _${i}`)
.classed('dc-tabbable', this._keyboardAccessible);
}
_createSlicePath (slicesEnter, arcs) {
const slicePath = slicesEnter.append('path')
.attr('fill', (d, i) => this._fill(d, i))
.on('click', d3compat.eventHandler(d => this._onClick(d)))
.attr('d', (d, i) => this._safeArc(d, i, arcs));
if (this._keyboardAccessible) {
this._makeKeyboardAccessible(this._onClick);
}
const tranNodes = transition(slicePath, this.transitionDuration(), this.transitionDelay());
if (tranNodes.attrTween) {
const chart = this;
tranNodes.attrTween('d', function (d) {
return chart._tweenPie(d, this);
});
}
}
_createTitles (slicesEnter) {
if (this.renderTitle()) {
slicesEnter.append('title').text(d => this.title()(d.data));
}
}
_applyLabelText (labels) {
labels
.text(d => {
const data = d.data;
if ((this._sliceHasNoData(data) || this._sliceTooSmall(d)) && !this._isSelectedSlice(d)) {
return '';
}
return this.label()(d.data);
});
}
_positionLabels (labels, arcs) {
this._applyLabelText(labels);
transition(labels, this.transitionDuration(), this.transitionDelay())
.attr('transform', d => this._labelPosition(d, arcs))
.attr('text-anchor', 'middle');
}
_highlightSlice (i, whether) {
this.select(`g.pie-slice._${i}`)
.classed('highlight', whether);
}
_createLabels (labels, pieData, arcs) {
if (this.renderLabel()) {
const labelsEnter = labels
.enter()
.append('text')
.attr('class', (d, i) => {
let classes = `${this._sliceCssClass} ${this._labelCssClass} _${i}`;
if (this._externalLabelRadius) {
classes += ' external';
}
return classes;
})
.on('click', d3compat.eventHandler(d => this._onClick(d)))
.on('mouseover', d3compat.eventHandler(d => {
this._highlightSlice(d.index, true);
}))
.on('mouseout', d3compat.eventHandler(d => {
this._highlightSlice(d.index, false);
}));
this._positionLabels(labelsEnter, arcs);
if (this._externalLabelRadius && this._drawPaths) {
this._updateLabelPaths(pieData, arcs);
}
}
}
_updateLabelPaths (pieData, arcs) {
let polyline = this._g.selectAll(`polyline.${this._sliceCssClass}`)
.data(pieData);
polyline.exit().remove();
polyline = polyline
.enter()
.append('polyline')
.attr('class', (d, i) => `pie-path _${i} ${this._sliceCssClass}`)
.on('click', d3compat.eventHandler(d => this._onClick(d)))
.on('mouseover', d3compat.eventHandler(d => {
this._highlightSlice(d.index, true);
}))
.on('mouseout', d3compat.eventHandler(d => {
this._highlightSlice(d.index, false);
}))
.merge(polyline);
const arc2 = arc()
.outerRadius(this._radius - this._externalRadiusPadding + this._externalLabelRadius)
.innerRadius(this._radius - this._externalRadiusPadding);
const tranNodes = transition(polyline, this.transitionDuration(), this.transitionDelay());
// this is one rare case where d3.selection differs from d3.transition
if (tranNodes.attrTween) {
tranNodes
.attrTween('points', function (d) {
let current = this._current || d;
current = {startAngle: current.startAngle, endAngle: current.endAngle};
const _interpolate = interpolate(current, d);
this._current = _interpolate(0);
return t => {
const d2 = _interpolate(t);
return [arcs.centroid(d2), arc2.centroid(d2)];
};
});
} else {
tranNodes.attr('points', d => [arcs.centroid(d), arc2.centroid(d)]);
}
tranNodes.style('visibility', d => d.endAngle - d.startAngle < 0.0001 ? 'hidden' : 'visible');
}
_updateElements (pieData, arcs) {
this._updateSlicePaths(pieData, arcs);
this._updateLabels(pieData, arcs);
this._updateTitles(pieData);
}
_updateSlicePaths (pieData, arcs) {
const slicePaths = this._g.selectAll(`g.${this._sliceCssClass}`)
.data(pieData)
.select('path')
.attr('d', (d, i) => this._safeArc(d, i, arcs));
const tranNodes = transition(slicePaths, this.transitionDuration(), this.transitionDelay());
if (tranNodes.attrTween) {
const chart = this;
tranNodes.attrTween('d', function (d) {
return chart._tweenPie(d, this);
});
}
tranNodes.attr('fill', (d, i) => this._fill(d, i));
}
_updateLabels (pieData, arcs) {
if (this.renderLabel()) {
const labels = this._g.selectAll(`text.${this._labelCssClass}`)
.data(pieData);
this._positionLabels(labels, arcs);
if (this._externalLabelRadius && this._drawPaths) {
this._updateLabelPaths(pieData, arcs);
}
}
}
_updateTitles (pieData) {
if (this.renderTitle()) {
this._g.selectAll(`g.${this._sliceCssClass}`)
.data(pieData)
.select('title')
.text(d => this.title()(d.data));
}
}
_removeElements (slices, labels) {
slices.exit().remove();
labels.exit().remove();
}
_highlightFilter () {
const chart = this;
if (this.hasFilter()) {
this.selectAll(`g.${this._sliceCssClass}`).each(function (d) {
if (chart._isSelectedSlice(d)) {
chart.highlightSelected(this);
} else {
chart.fadeDeselected(this);
}
});
} else {
this.selectAll(`g.${this._sliceCssClass}`).each(function () {
chart.resetHighlight(this);
});
}
}
/**
* Get or set the external radius padding of the pie chart. This will force the radius of the
* pie chart to become smaller or larger depending on the value.
* @param {Number} [externalRadiusPadding=0]
* @returns {Number|PieChart}
*/
externalRadiusPadding (externalRadiusPadding) {
if (!arguments.length) {
return this._externalRadiusPadding;
}
this._externalRadiusPadding = externalRadiusPadding;
return this;
}
/**
* Get or set the inner radius of the pie chart. If the inner radius is greater than 0px then the
* pie chart will be rendered as a doughnut chart.
* @param {Number} [innerRadius=0]
* @returns {Number|PieChart}
*/
innerRadius (innerRadius) {
if (!arguments.length) {
return this._innerRadius;
}
this._innerRadius = innerRadius;
return this;
}
/**
* Get or set the outer radius. If the radius is not set, it will be half of the minimum of the
* chart width and height.
* @param {Number} [radius]
* @returns {Number|PieChart}
*/
radius (radius) {
if (!arguments.length) {
return this._givenRadius;
}
this._givenRadius = radius;
return this;
}
/**
* Get or set center x coordinate position. Default is center of svg.
* @param {Number} [cx]
* @returns {Number|PieChart}
*/
cx (cx) {
if (!arguments.length) {
return (this._cx || this.width() / 2);
}
this._cx = cx;
return this;
}
/**
* Get or set center y coordinate position. Default is center of svg.
* @param {Number} [cy]
* @returns {Number|PieChart}
*/
cy (cy) {
if (!arguments.length) {
return (this._cy || this.height() / 2);
}
this._cy = cy;
return this;
}
_buildArcs () {
return arc()
.outerRadius(this._radius - this._externalRadiusPadding)
.innerRadius(this._innerRadius);
}
_isSelectedSlice (d) {
return this.hasFilter(this.cappedKeyAccessor(d.data));
}
_doRedraw () {
this._drawChart();
return this;
}
/**
* Get or set the minimal slice angle for label rendering. Any slice with a smaller angle will not
* display a slice label.
* @param {Number} [minAngleForLabel=0.5]
* @returns {Number|PieChart}
*/
minAngleForLabel (minAngleForLabel) {
if (!arguments.length) {
return this._minAngleForLabel;
}
this._minAngleForLabel = minAngleForLabel;
return this;
}
_pieLayout () {
return pie().sort(null).value(d => this.cappedValueAccessor(d));
}
_sliceTooSmall (d) {
const angle = (d.endAngle - d.startAngle);
return isNaN(angle) || angle < this._minAngleForLabel;
}
_sliceHasNoData (d) {
return this.cappedValueAccessor(d) === 0;
}
_isOffCanvas (current) {
return !current || isNaN(current.startAngle) || isNaN(current.endAngle);
}
_fill (d, i) {
return this.getColor(d.data, i);
}
_onClick (d) {
if (this._g.attr('class') !== this._emptyCssClass) {
this.onClick(d.data);
}
}
_safeArc (d, i, _arc) {
let path = _arc(d, i);
if (path.indexOf('NaN') >= 0) {
path = 'M0,0';
}
return path;
}
/**
* Title to use for the only slice when there is no data.
* @param {String} [title]
* @returns {String|PieChart}
*/
emptyTitle (title) {
if (arguments.length === 0) {
return this._emptyTitle;
}
this._emptyTitle = title;
return this;
}
/**
* Position slice labels offset from the outer edge of the chart.
*
* The argument specifies the extra radius to be added for slice labels.
* @param {Number} [externalLabelRadius]
* @returns {Number|PieChart}
*/
externalLabels (externalLabelRadius) {
if (arguments.length === 0) {
return this._externalLabelRadius;
} else if (externalLabelRadius) {
this._externalLabelRadius = externalLabelRadius;
} else {
this._externalLabelRadius = undefined;
}
return this;
}
/**
* Get or set whether to draw lines from pie slices to their labels.
*
* @param {Boolean} [drawPaths]
* @returns {Boolean|PieChart}
*/
drawPaths (drawPaths) {
if (arguments.length === 0) {
return this._drawPaths;
}
this._drawPaths = drawPaths;
return this;
}
_labelPosition (d, _arc) {
let centroid;
if (this._externalLabelRadius) {
centroid = arc()
.outerRadius(this._radius - this._externalRadiusPadding + this._externalLabelRadius)
.innerRadius(this._radius - this._externalRadiusPadding + this._externalLabelRadius)
.centroid(d);
} else {
centroid = _arc.centroid(d);
}
if (isNaN(centroid[0]) || isNaN(centroid[1])) {
return 'translate(0,0)';
} else {
return `translate(${centroid})`;
}
}
legendables () {
return this.data().map((d, i) => {
const legendable = {name: d.key, data: d.value, others: d.others, chart: this};
legendable.color = this.getColor(d, i);
return legendable;
});
}
legendHighlight (d) {
this._highlightSliceFromLegendable(d, true);
}
legendReset (d) {
this._highlightSliceFromLegendable(d, false);
}
legendToggle (d) {
this.onClick({key: d.name, others: d.others});
}
_highlightSliceFromLegendable (legendable, highlighted) {
this.selectAll('g.pie-slice').each(function (d) {
if (legendable.name === d.data.key) {
select(this).classed('highlight', highlighted);
}
});
}
_tweenPie (b, element) {
b.innerRadius = this._innerRadius;
let current = element._current;
if (this._isOffCanvas(current)) {
current = {startAngle: 0, endAngle: 0};
} else {
// only interpolate startAngle & endAngle, not the whole data object
current = {startAngle: current.startAngle, endAngle: current.endAngle};
}
const i = interpolate(current, b);
element._current = i(0);
return t => this._safeArc(i(t), 0, this._buildArcs());
}
}
export const pieChart = (parent, chartGroup) => new PieChart(parent, chartGroup);