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