Source: sunburst-chart.js

/**
 * The sunburst chart implementation is usually used to visualize a small tree distribution.  The sunburst
 * 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 dc.baseMixin#ordering ordering} which defaults to sorting
 * by key.
 *
 * The keys used in the sunburst chart should be arrays, representing paths in the tree.
 *
 * When filtering, the sunburst chart creates instances of {@link dc.filters.HierarchyFilter HierarchyFilter}.
 *
 * @class sunburstChart
 * @memberof dc
 * @mixes dc.capMixin
 * @mixes dc.colorMixin
 * @mixes dc.baseMixin
 * @example
 * // create a sunburst chart under #chart-container1 element using the default global chart group
 * var chart1 = dc.sunburstChart('#chart-container1');
 * // create a sunburst chart under #chart-container2 element using chart group A
 * var chart2 = dc.sunburstChart('#chart-container2', 'chartGroupA');
 *
 * @param {String|node|d3.selection} parent - Any valid
 * {@link https://github.com/d3/d3-3.x-api-reference/blob/master/Selections.md#selecting-elements 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.
 * @returns {dc.sunburstChart}
 **/
dc.sunburstChart = function (parent, chartGroup) {
    var DEFAULT_MIN_ANGLE_FOR_LABEL = 0.5;

    var _sliceCssClass = 'pie-slice';
    var _emptyCssClass = 'empty-chart';
    var _emptyTitle = 'empty';

    var _radius,
        _givenRadius, // given radius, if any
        _innerRadius = 0,
        _ringSizes;

    var _g;
    var _cx;
    var _cy;
    var _minAngleForLabel = DEFAULT_MIN_ANGLE_FOR_LABEL;
    var _externalLabelRadius;
    var _chart = dc.capMixin(dc.colorMixin(dc.baseMixin({})));
    _chart.colorAccessor(_chart.cappedKeyAccessor);

    // override cap mixin
    _chart.ordering(dc.pluck('key'));

    // Handle cases if value corresponds to generated parent nodes
    function extendedValueAccessor (d) {
        if (d.path) {
            return d.value;
        }
        return _chart.cappedValueAccessor(d);
    }

    function scaleRadius (ringIndex, y) {
        if (ringIndex === 0) {
            return _innerRadius;
        } else {
            var customRelativeRadius = d3.sum(_chart.ringSizes().relativeRingSizes.slice(0, ringIndex));
            var scaleFactor = (ringIndex * (1 / _chart.ringSizes().relativeRingSizes.length)) / customRelativeRadius;
            var standardRadius = (y - _chart.ringSizes().rootOffset) / (1 - _chart.ringSizes().rootOffset) * (_radius - _innerRadius);
            return _innerRadius + standardRadius / scaleFactor;
        }
    }

    _chart.title(function (d) {
        return _chart.cappedKeyAccessor(d) + ': ' + extendedValueAccessor(d);
    });

    _chart.label(_chart.cappedKeyAccessor);
    _chart.renderLabel(true);

    _chart.transitionDuration(350);

    _chart.filterHandler(function (dimension, filters) {
        if (filters.length === 0) {
            dimension.filter(null);
        } else {
            dimension.filterFunction(function (d) {
                for (var i = 0; i < filters.length; i++) {
                    var filter = filters[i];
                    if (filter.isFiltered && filter.isFiltered(d)) {
                        return true;
                    }
                }
                return false;
            });
        }
        return filters;
    });

    _chart._doRender = function () {
        _chart.resetSvg();

        _g = _chart.svg()
            .append('g')
            .attr('transform', 'translate(' + _chart.cx() + ',' + _chart.cy() + ')');

        drawChart();

        return _chart;
    };

    function drawChart () {
        // set radius from chart size if none given, or if given radius is too large
        var maxRadius =  d3.min([_chart.width(), _chart.height()]) / 2;
        _radius = _givenRadius && _givenRadius < maxRadius ? _givenRadius : maxRadius;

        var arc = buildArcs();

        var partitionedNodes, cdata;
        // if we have data...
        if (d3.sum(_chart.data(), _chart.valueAccessor())) {
            cdata = dc.utils.toHierarchy(_chart.data(), _chart.valueAccessor());
            partitionedNodes = partitionNodes(cdata);
            // First one is the root, which is not needed
            partitionedNodes.nodes.shift();
            _g.classed(_emptyCssClass, false);
        } else {
            // otherwise we'd be getting NaNs, so override
            // note: abuse others for its ignoring the value accessor
            cdata = dc.utils.toHierarchy([], function (d) {
                return d.value;
            });
            partitionedNodes = partitionNodes(cdata);
            _g.classed(_emptyCssClass, true);
        }
        _chart.ringSizes().rootOffset = partitionedNodes.rootOffset;
        _chart.ringSizes().relativeRingSizes = partitionedNodes.relativeRingSizes;

        if (_g) {
            var slices = _g.selectAll('g.' + _sliceCssClass)
                .data(partitionedNodes.nodes);
            createElements(slices, arc, partitionedNodes.nodes);

            updateElements(partitionedNodes.nodes, arc);

            removeElements(slices);

            highlightFilter();

            dc.transition(_g, _chart.transitionDuration(), _chart.transitionDelay())
                .attr('transform', 'translate(' + _chart.cx() + ',' + _chart.cy() + ')');
        }
    }

    function createElements (slices, arc, sunburstData) {
        var slicesEnter = createSliceNodes(slices);

        createSlicePath(slicesEnter, arc);
        createTitles(slicesEnter);
        createLabels(sunburstData, arc);
    }

    function createSliceNodes (slices) {
        var slicesEnter = slices
            .enter()
            .append('g')
            .attr('class', function (d, i) {
                return _sliceCssClass +
                    ' _' + i + ' ' +
                    _sliceCssClass + '-level-' + d.depth;
            });
        return slicesEnter;
    }

    function createSlicePath (slicesEnter, arc) {
        var slicePath = slicesEnter.append('path')
            .attr('fill', fill)
            .on('click', onClick)
            .attr('d', function (d) {
                return safeArc(arc, d);
            });

        var transition = dc.transition(slicePath, _chart.transitionDuration());
        if (transition.attrTween) {
            transition.attrTween('d', tweenSlice);
        }
    }

    function createTitles (slicesEnter) {
        if (_chart.renderTitle()) {
            slicesEnter.append('title').text(function (d) {
                return _chart.title()(d);
            });
        }
    }

    function positionLabels (labelsEnter, arc) {
        dc.transition(labelsEnter, _chart.transitionDuration())
            .attr('transform', function (d) {
                return labelPosition(d, arc);
            })
            .attr('text-anchor', 'middle')
            .text(function (d) {
                // position label...
                if (sliceHasNoData(d) || sliceTooSmall(d)) {
                    return '';
                }
                return _chart.label()(d);
            });
    }

    function createLabels (sunburstData, arc) {
        if (_chart.renderLabel()) {
            var labels = _g.selectAll('text.' + _sliceCssClass)
                .data(sunburstData);

            labels.exit().remove();

            var labelsEnter = labels
                .enter()
                .append('text')
                .attr('class', function (d, i) {
                    var classes = _sliceCssClass + ' _' + i;
                    if (_externalLabelRadius) {
                        classes += ' external';
                    }
                    return classes;
                })
                .on('click', onClick);
            positionLabels(labelsEnter, arc);
        }
    }

    function updateElements (sunburstData, arc) {
        updateSlicePaths(sunburstData, arc);
        updateLabels(sunburstData, arc);
        updateTitles(sunburstData);
    }

    function updateSlicePaths (sunburstData, arc) {
        var slicePaths = _g.selectAll('g.' + _sliceCssClass)
            .data(sunburstData)
            .select('path')
            .attr('d', function (d, i) {
                return safeArc(arc, d);
            });
        var transition = dc.transition(slicePaths, _chart.transitionDuration());
        if (transition.attrTween) {
            transition.attrTween('d', tweenSlice);
        }
        transition.attr('fill', fill);
    }

    function updateLabels (sunburstData, arc) {
        if (_chart.renderLabel()) {
            var labels = _g.selectAll('text.' + _sliceCssClass)
                .data(sunburstData);
            positionLabels(labels, arc);
        }
    }

    function updateTitles (sunburstData) {
        if (_chart.renderTitle()) {
            _g.selectAll('g.' + _sliceCssClass)
                .data(sunburstData)
                .select('title')
                .text(function (d) {
                    return _chart.title()(d);
                });
        }
    }

    function removeElements (slices) {
        slices.exit().remove();
    }

    function highlightFilter () {
        if (_chart.hasFilter()) {
            _chart.selectAll('g.' + _sliceCssClass).each(function (d) {
                if (isSelectedSlice(d)) {
                    _chart.highlightSelected(this);
                } else {
                    _chart.fadeDeselected(this);
                }
            });
        } else {
            _chart.selectAll('g.' + _sliceCssClass).each(function (d) {
                _chart.resetHighlight(this);
            });
        }
    }

    /**
     * Get or set the inner radius of the sunburst chart. If the inner radius is greater than 0px then the
     * sunburst chart will be rendered as a doughnut chart. Default inner radius is 0px.
     * @method innerRadius
     * @memberof dc.sunburstChart
     * @instance
     * @param {Number} [innerRadius=0]
     * @returns {Number|dc.sunburstChart}
     */
    _chart.innerRadius = function (innerRadius) {
        if (!arguments.length) {
            return _innerRadius;
        }
        _innerRadius = innerRadius;
        return _chart;
    };

    /**
     * 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.
     * @method radius
     * @memberof dc.sunburstChart
     * @instance
     * @param {Number} [radius]
     * @returns {Number|dc.sunburstChart}
     */
    _chart.radius = function (radius) {
        if (!arguments.length) {
            return _givenRadius;
        }
        _givenRadius = radius;
        return _chart;
    };

    /**
     * Get or set center x coordinate position. Default is center of svg.
     * @method cx
     * @memberof dc.sunburstChart
     * @instance
     * @param {Number} [cx]
     * @returns {Number|dc.sunburstChart}
     */
    _chart.cx = function (cx) {
        if (!arguments.length) {
            return (_cx || _chart.width() / 2);
        }
        _cx = cx;
        return _chart;
    };

    /**
     * Get or set center y coordinate position. Default is center of svg.
     * @method cy
     * @memberof dc.sunburstChart
     * @instance
     * @param {Number} [cy]
     * @returns {Number|dc.sunburstChart}
     */
    _chart.cy = function (cy) {
        if (!arguments.length) {
            return (_cy || _chart.height() / 2);
        }
        _cy = cy;
        return _chart;
    };

    /**
     * Get or set the minimal slice angle for label rendering. Any slice with a smaller angle will not
     * display a slice label.
     * @method minAngleForLabel
     * @memberof dc.sunburstChart
     * @instance
     * @param {Number} [minAngleForLabel=0.5]
     * @returns {Number|dc.sunburstChart}
     */
    _chart.minAngleForLabel = function (minAngleForLabel) {
        if (!arguments.length) {
            return _minAngleForLabel;
        }
        _minAngleForLabel = minAngleForLabel;
        return _chart;
    };

    /**
     * Title to use for the only slice when there is no data.
     * @method emptyTitle
     * @memberof dc.sunburstChart
     * @instance
     * @param {String} [title]
     * @returns {String|dc.sunburstChart}
     */
    _chart.emptyTitle = function (title) {
        if (arguments.length === 0) {
            return _emptyTitle;
        }
        _emptyTitle = title;
        return _chart;
    };

    /**
     * Position slice labels offset from the outer edge of the chart.
     *
     * The argument specifies the extra radius to be added for slice labels.
     * @method externalLabels
     * @memberof dc.sunburstChart
     * @instance
     * @param {Number} [externalLabelRadius]
     * @returns {Number|dc.sunburstChart}
     */
    _chart.externalLabels = function (externalLabelRadius) {
        if (arguments.length === 0) {
            return _externalLabelRadius;
        } else if (externalLabelRadius) {
            _externalLabelRadius = externalLabelRadius;
        } else {
            _externalLabelRadius = undefined;
        }

        return _chart;
    };

    /**
     * Constructs the default RingSizes parameter for {@link dc.sunburstChart#ringSizes ringSizes()},
     * which makes the rings narrower as they get farther away from the center.
     *
     * Can be used as a parameter to ringSizes() to reset the default behavior, or modified for custom ring sizes.
     *
     * @method defaultRingSizes
     * @memberof dc.sunburstChart
     * @instance
     * @example
     *   var chart = new dc.sunburstChart(...);
     *   chart.ringSizes(chart.defaultRingSizes())
     * @returns {RingSizes}
     */
    _chart.defaultRingSizes = function () {
        return {
            partitionDy: function () {
                return _radius * _radius;
            },
            scaleInnerRadius: function (d) {
                return d.data.path && d.data.path.length === 1 ? _innerRadius : Math.sqrt(d.y0);
            },
            scaleOuterRadius: function (d) {
                return Math.sqrt(d.y1);
            },
            relativeRingSizesFunction: function () {return [];}
        };
    };

    /**
     * Constructs a RingSizes parameter for {@link dc.sunburstChart#ringSizes ringSizes()}
     * that will make the chart rings equally wide.
     *
     * @method equalRingSizes
     * @memberof dc.sunburstChart
     * @instance
     * @example
     *   var chart = new dc.sunburstChart(...);
     *   chart.ringSizes(chart.equalRingSizes())
     * @returns {RingSizes}
     */
    _chart.equalRingSizes = function () {
        return _chart.relativeRingSizes(
            function (ringCount) {
                var i;
                var result = [];
                for (i = 0; i < ringCount; i++) {
                    result.push(1 / ringCount);
                }
                return result;
            }
        );
    };

    /**
     * Constructs a RingSizes parameter for {@link dc.sunburstChart#ringSizes ringSizes()} using the given function
     * to determine each rings width.
     *
     * * The function must return an array containing portion values for each ring/level of the chart.
     * * The length of the array must match the number of rings of the chart at runtime, which is provided as the only
     *   argument.
     * * The sum of all portions from the array must be 1 (100%).
     *
     * @example
     * // specific relative portions (the number of rings (3) is known in this case)
     * chart.ringSizes(chart.relativeRingSizes(function (ringCount) {
     *     return [.1, .3, .6];
     * });
     * @method relativeRingSizes
     * @memberof dc.sunburstChart
     * @instance
     * @param {Function} [relativeRingSizesFunction]
     * @returns {RingSizes}
     */
    _chart.relativeRingSizes = function (relativeRingSizesFunction) {
        function assertPortionsArray (relativeSizes, numberOfRings) {
            if (!Array.isArray(relativeSizes)) {
                throw new dc.errors.BadArgumentException('relativeRingSizes function must return an array');
            }

            var portionsSum = d3.sum(relativeSizes);
            if (Math.abs(portionsSum - 1) > dc.constants.NEGLIGIBLE_NUMBER) {
                throw new dc.errors.BadArgumentException(
                    'relativeRingSizes : portions must add up to 1, but sum was ' + portionsSum);
            }

            if (relativeSizes.length !== numberOfRings) {
                throw new dc.errors.BadArgumentException(
                    'relativeRingSizes : number of values must match number of rings (' +
                        numberOfRings + ') but was ' + relativeSizes.length);
            }
        }
        return {
            partitionDy: function () {
                return 1;
            },
            scaleInnerRadius: function (d) {
                return scaleRadius(d.data.path.length - 1, d.y0);
            },
            scaleOuterRadius: function (d) {
                return scaleRadius(d.data.path.length, d.y1);
            },
            relativeRingSizesFunction: function (ringCount) {
                var result = relativeRingSizesFunction(ringCount);
                assertPortionsArray(result, ringCount);
                return result;
            }
        };
    };

    /**
     * Get or set the strategy to use for sizing the charts rings.
     *
     * There are three strategies available
     * * {@link dc.sunburstChart#defaultRingSizes `defaultRingSizes`}: the rings get narrower farther away from the center
     * * {@link dc.sunburstChart#relativeRingSizes `relativeRingSizes`}: set the ring sizes as portions of 1
     * * {@link dc.sunburstChart#equalRingSizes `equalRingSizes`}: the rings are equally wide
     *
     * You can modify the returned strategy, or create your own, for custom ring sizing.
     *
     * RingSizes is a duck-typed interface that must support the following methods:
     * * `partitionDy()`: used for
     *   {@link https://github.com/d3/d3-hierarchy/blob/v1.1.9/README.md#partition_size `d3.partition.size`}
     * * `scaleInnerRadius(d)`: takes datum and returns radius for
     *    {@link https://github.com/d3/d3-shape/blob/v1.3.7/README.md#arc_innerRadius `d3.arc.innerRadius`}
     * * `scaleOuterRadius(d)`: takes datum and returns radius for
     *    {@link https://github.com/d3/d3-shape/blob/v1.3.7/README.md#arc_outerRadius `d3.arc.outerRadius`}
     * * `relativeRingSizesFunction(ringCount)`: takes ring count and returns an array of portions that
     *   must add up to 1
     *
     * @example
     * // make rings equally wide
     * chart.ringSizes(chart.equalRingSizes())
     * // reset to default behavior
     * chart.ringSizes(chart.defaultRingSizes()))
     * @method ringSizes
     * @memberof dc.sunburstChart
     * @instance
     * @param {RingSizes} ringSizes
     * @returns {Object|dc.sunburstChart}
     */
    _chart.ringSizes = function (ringSizes) {
        if (!arguments.length) {
            if (!_ringSizes) {
                _ringSizes = this.defaultRingSizes();
            }
            return _ringSizes;
        }
        _ringSizes = ringSizes;
        return _chart;
    };

    function buildArcs () {
        return d3.arc()
            .startAngle(function (d) {
                return d.x0;
            })
            .endAngle(function (d) {
                return d.x1;
            })
            .innerRadius(function (d) {
                return _chart.ringSizes().scaleInnerRadius(d);
            })
            .outerRadius(function (d) {
                return _chart.ringSizes().scaleOuterRadius(d);
            });
    }

    function isSelectedSlice (d) {
        return isPathFiltered(d.path);
    }

    function isPathFiltered (path) {
        for (var i = 0; i < _chart.filters().length; i++) {
            var currentFilter = _chart.filters()[i];
            if (currentFilter.isFiltered(path)) {
                return true;
            }
        }
        return false;
    }

    // returns all filters that are a parent or child of the path
    function filtersForPath (path) {
        var pathFilter = dc.filters.HierarchyFilter(path);
        var filters = [];
        for (var i = 0; i < _chart.filters().length; i++) {
            var currentFilter = _chart.filters()[i];
            if (currentFilter.isFiltered(path) || pathFilter.isFiltered(currentFilter)) {
                filters.push(currentFilter);
            }
        }
        return filters;
    }

    _chart._doRedraw = function () {
        drawChart();
        return _chart;
    };

    function partitionNodes (data) {
        var getSortable = function (d) {
            return {'key': d.data.key, 'value': d.value};
        };
        // The changes picked up from https://github.com/d3/d3-hierarchy/issues/50
        var hierarchy = d3.hierarchy(data)
            .sum(function (d) {
                return d.children ? 0 : extendedValueAccessor(d);
            })
            .sort(function (a, b) {
                return d3.ascending(_chart.ordering()(getSortable(a)), _chart.ordering()(getSortable(b)));
            });

        var partition = d3.partition().size([2 * Math.PI, _chart.ringSizes().partitionDy()]);

        partition(hierarchy);

        // In D3v4 the returned data is slightly different, change it enough to suit our purposes.
        var nodes = hierarchy.descendants().map(function (d) {
            d.key = d.data.key;
            d.path = d.data.path;
            return d;
        });

        var relativeSizes = _chart.ringSizes().relativeRingSizesFunction(hierarchy.height);

        return {
            nodes: nodes,
            rootOffset: hierarchy.y1,
            relativeRingSizes: relativeSizes
        };
    }

    function sliceTooSmall (d) {
        var angle = d.x1 - d.x0;
        return isNaN(angle) || angle < _minAngleForLabel;
    }

    function sliceHasNoData (d) {
        return extendedValueAccessor(d) === 0;
    }

    function tweenSlice (d) {
        var current = this._current;
        if (isOffCanvas(current)) {
            current = {x0: 0, x1: 0, y0: 0, y1: 0};
        }
        var tweenTarget = {
            x0: d.x0,
            x1: d.x1,
            y0: d.y0,
            y1: d.y1
        };
        var i = d3.interpolate(current, tweenTarget);
        this._current = i(0);
        return function (t) {
            return safeArc(buildArcs(), Object.assign({}, d, i(t)));
        };
    }

    function isOffCanvas (d) {
        return !d || isNaN(d.x0) || isNaN(d.y0);
    }

    function fill (d, i) {
        return _chart.getColor(d.data, i);
    }

    function _onClick (d) {
        // Clicking on Legends do not filter, it throws exception
        // Must be better way to handle this, in legends we need to access `d.key`
        var path = d.path || d.key;
        var filter = dc.filters.HierarchyFilter(path);

        // filters are equal to, parents or children of the path.
        var filters = filtersForPath(path);
        var exactMatch = false;
        // clear out any filters that cover the path filtered.
        for (var i = filters.length - 1; i >= 0; i--) {
            var currentFilter = filters[i];
            if (dc.utils.arraysIdentical(currentFilter, path)) {
                exactMatch = true;
            }
            _chart.filter(filters[i]);
        }
        dc.events.trigger(function () {
            // if it is a new filter - put it in.
            if (!exactMatch) {
                _chart.filter(filter);
            }
            _chart.redrawGroup();
        });
    }

    _chart.onClick = onClick;

    function onClick (d, i) {
        if (_g.attr('class') !== _emptyCssClass) {
            _onClick(d, i);
        }
    }

    function safeArc (arc, d) {
        var path = arc(d);
        if (path.indexOf('NaN') >= 0) {
            path = 'M0,0';
        }
        return path;
    }

    function labelPosition (d, arc) {
        var centroid;
        if (_externalLabelRadius) {
            centroid = d3.svg.arc()
                .outerRadius(_radius + _externalLabelRadius)
                .innerRadius(_radius + _externalLabelRadius)
                .centroid(d);
        } else {
            centroid = arc.centroid(d);
        }
        if (isNaN(centroid[0]) || isNaN(centroid[1])) {
            return 'translate(0,0)';
        } else {
            return 'translate(' + centroid + ')';
        }
    }

    _chart.legendables = function () {
        return _chart.data().map(function (d, i) {
            var legendable = {name: d.key, data: d.value, others: d.others, chart: _chart};
            legendable.color = _chart.getColor(d, i);
            return legendable;
        });
    };

    _chart.legendHighlight = function (d) {
        highlightSliceFromLegendable(d, true);
    };

    _chart.legendReset = function (d) {
        highlightSliceFromLegendable(d, false);
    };

    _chart.legendToggle = function (d) {
        _chart.onClick({key: d.name, others: d.others});
    };

    function highlightSliceFromLegendable (legendable, highlighted) {
        _chart.selectAll('g.pie-slice').each(function (d) {
            if (legendable.name === d.key) {
                d3.select(this).classed('highlight', highlighted);
            }
        });
    }

    return _chart.anchor(parent, chartGroup);
};