Source: charts/sunburst-chart.js

import {hierarchy, partition} from 'd3-hierarchy';
import {ascending, min, sum} from 'd3-array';
import {arc} from 'd3-shape';
import {select} from 'd3-selection';
import {interpolate} from 'd3-interpolate';

import {transition} from '../core/core';
import {filters} from '../core/filters';
import {utils, pluck} from '../core/utils';
import {d3compat} from '../core/config';
import {events} from '../core/events';
import {ColorMixin} from '../base/color-mixin';
import {BaseMixin} from '../base/base-mixin';
import {constants} from '../core/constants';
import {BadArgumentException} from '../core/bad-argument-exception';

const DEFAULT_MIN_ANGLE_FOR_LABEL = 0.5;

/**
 * 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 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 Filters.HierarchyFilter HierarchyFilter}.
 *
 * @mixes CapMixin
 * @mixes ColorMixin
 * @mixes BaseMixin
 */
export class SunburstChart extends ColorMixin(BaseMixin) {
    /**
     * Create a Sunburst Chart
     * @example
     * // create a sunburst chart under #chart-container1 element using the default global chart group
     * var chart1 = new SunburstChart('#chart-container1');
     * // create a sunburst chart under #chart-container2 element using chart group A
     * var chart2 = new 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.
     */
    constructor (parent, chartGroup) {
        super();

        this._sliceCssClass = 'pie-slice';
        this._emptyCssClass = 'empty-chart';
        this._emptyTitle = 'empty';

        this._radius = undefined;
        this._givenRadius = undefined; // given radius, if any
        this._innerRadius = 0;
        this._ringSizes = null;

        this._g = undefined;
        this._cx = undefined;
        this._cy = undefined;
        this._minAngleForLabel = DEFAULT_MIN_ANGLE_FOR_LABEL;
        this._externalLabelRadius = undefined;

        this.colorAccessor(d => this.keyAccessor()(d));

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

        this.title(d => `${this.keyAccessor()(d)}: ${this._extendedValueAccessor(d)}`);

        this.label(d => this.keyAccessor()(d));
        this.renderLabel(true);

        this.transitionDuration(350);

        this.anchor(parent, chartGroup);
    }

    // Handle cases if value corresponds to generated parent nodes
    _extendedValueAccessor (d) {
        if (d.path) {
            return d.value;
        }
        return this.valueAccessor()(d);
    }

    _scaleRadius (ringIndex, y) {
        if (ringIndex === 0) {
            return this._innerRadius;
        } else {
            const customRelativeRadius = sum(this.ringSizes().relativeRingSizes.slice(0, ringIndex));
            const scaleFactor = (ringIndex * (1 / this.ringSizes().relativeRingSizes.length)) /
                  customRelativeRadius;
            const standardRadius = (y - this.ringSizes().rootOffset) /
                  (1 - this.ringSizes().rootOffset) * (this._radius - this._innerRadius);
            return this._innerRadius + standardRadius / scaleFactor;
        }
    }

    _doRender () {
        this.resetSvg();

        this._g = this.svg()
            .append('g')
            .attr('transform', `translate(${this.cx()},${this.cy()})`);

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

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

        if (this._g) {
            const slices = this._g.selectAll(`g.${this._sliceCssClass}`)
                .data(partitionedNodes.nodes);
            this._createElements(slices, arcs, partitionedNodes.nodes);

            this._updateElements(partitionedNodes.nodes, arcs);

            this._removeElements(slices);

            this._highlightFilter();

            transition(this._g, this.transitionDuration(), this.transitionDelay())
                .attr('transform', `translate(${this.cx()},${this.cy()})`);
        }
    }

    _createElements (slices, arcs, sunburstData) {
        const slicesEnter = this._createSliceNodes(slices);

        this._createSlicePath(slicesEnter, arcs);
        this._createTitles(slicesEnter);
        this._createLabels(sunburstData, arcs);
    }

    _createSliceNodes (slices) {
        return slices
            .enter()
            .append('g')
            .attr('class', (d, i) => `${this._sliceCssClass
            } _${i} ${
                this._sliceCssClass}-level-${d.depth}`);
    }

    _createSlicePath (slicesEnter, arcs) {
        const slicePath = slicesEnter.append('path')
            .attr('fill', (d, i) => this._fill(d, i))
            .on('click', d3compat.eventHandler(d => this.onClick(d)))
            .classed('dc-tabbable', this._keyboardAccessible)
            .attr('d', d => this._safeArc(arcs, d));

        if (this._keyboardAccessible) {
            this._makeKeyboardAccessible(this.onClick);
        }

        const tranNodes = transition(slicePath, this.transitionDuration());
        if (tranNodes.attrTween) {
            const chart = this;
            tranNodes.attrTween('d', function (d) {
                return chart._tweenSlice(d, this);
            });
        }
    }

    _createTitles (slicesEnter) {
        if (this.renderTitle()) {
            slicesEnter.append('title').text(d => this.title()(d));
        }
    }

    _positionLabels (labelsEnter, arcs) {
        transition(labelsEnter, this.transitionDuration())
            .attr('transform', d => this._labelPosition(d, arcs))
            .attr('text-anchor', 'middle')
            .text(d => {
                // position label...
                if (this._sliceHasNoData(d) || this._sliceTooSmall(d)) {
                    return '';
                }
                return this.label()(d);
            });
    }

    _createLabels (sunburstData, arcs) {
        if (this.renderLabel()) {
            const labels = this._g.selectAll(`text.${this._sliceCssClass}`)
                .data(sunburstData);

            labels.exit().remove();

            const labelsEnter = labels
                .enter()
                .append('text')
                .attr('class', (d, i) => {
                    let classes = `${this._sliceCssClass} _${i}`;
                    if (this._externalLabelRadius) {
                        classes += ' external';
                    }
                    return classes;
                })
                .on('click', d3compat.eventHandler(d => this.onClick(d)));
            this._positionLabels(labelsEnter, arcs);
        }
    }

    _updateElements (sunburstData, arcs) {
        this._updateSlicePaths(sunburstData, arcs);
        this._updateLabels(sunburstData, arcs);
        this._updateTitles(sunburstData);
    }

    _updateSlicePaths (sunburstData, arcs) {
        const slicePaths = this._g.selectAll(`g.${this._sliceCssClass}`)
            .data(sunburstData)
            .select('path')
            .attr('d', (d, i) => this._safeArc(arcs, d));
        const tranNodes = transition(slicePaths, this.transitionDuration());
        if (tranNodes.attrTween) {
            const chart = this;
            tranNodes.attrTween('d', function (d) {
                return chart._tweenSlice(d, this);
            });
        }
        tranNodes.attr('fill', (d, i) => this._fill(d, i));
    }

    _updateLabels (sunburstData, arcs) {
        if (this.renderLabel()) {
            const labels = this._g.selectAll(`text.${this._sliceCssClass}`)
                .data(sunburstData);
            this._positionLabels(labels, arcs);
        }
    }

    _updateTitles (sunburstData) {
        if (this.renderTitle()) {
            this._g.selectAll(`g.${this._sliceCssClass}`)
                .data(sunburstData)
                .select('title')
                .text(d => this.title()(d));
        }
    }

    _removeElements (slices) {
        slices.exit().remove();
    }

    _highlightFilter () {
        const chart = this;
        if (chart.hasFilter()) {
            chart.selectAll(`g.${chart._sliceCssClass}`).each(function (d) {
                if (chart._isSelectedSlice(d)) {
                    chart.highlightSelected(this);
                } else {
                    chart.fadeDeselected(this);
                }
            });
        } else {
            chart.selectAll(`g.${chart._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.
     * @param {Number} [innerRadius=0]
     * @returns {Number|SunburstChart}
     */
    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|SunburstChart}
     */
    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|SunburstChart}
     */
    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|SunburstChart}
     */
    cy (cy) {
        if (!arguments.length) {
            return (this._cy || this.height() / 2);
        }
        this._cy = cy;
        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|SunburstChart}
     */
    minAngleForLabel (minAngleForLabel) {
        if (!arguments.length) {
            return this._minAngleForLabel;
        }
        this._minAngleForLabel = minAngleForLabel;
        return this;
    }

    /**
     * Title to use for the only slice when there is no data.
     * @param {String} [title]
     * @returns {String|SunburstChart}
     */
    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|SunburstChart}
     */
    externalLabels (externalLabelRadius) {
        if (arguments.length === 0) {
            return this._externalLabelRadius;
        } else if (externalLabelRadius) {
            this._externalLabelRadius = externalLabelRadius;
        } else {
            this._externalLabelRadius = undefined;
        }

        return this;
    }

    /**
     * Constructs the default RingSizes parameter for {@link 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.
     *
     * @example
     *   var chart = new dc.SunburstChart(...);
     *   chart.ringSizes(chart.defaultRingSizes())
     * @returns {RingSizes}
     */
    defaultRingSizes () {
        return {
            partitionDy: () => this._radius * this._radius,
            scaleInnerRadius: d => d.data.path && d.data.path.length === 1 ?
                this._innerRadius :
                Math.sqrt(d.y0),
            scaleOuterRadius: d => Math.sqrt(d.y1),
            relativeRingSizesFunction: () => []
        };
    }

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

    /**
     * Constructs a RingSizes parameter for {@link 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];
     * });
     * @param {Function} [relativeRingSizesFunction]
     * @returns {RingSizes}
     */
    relativeRingSizes (relativeRingSizesFunction) {
        function assertPortionsArray (relativeSizes, numberOfRings) {
            if (!Array.isArray(relativeSizes)) {
                throw new BadArgumentException('relativeRingSizes function must return an array');
            }

            const portionsSum = sum(relativeSizes);
            if (Math.abs(portionsSum - 1) > constants.NEGLIGIBLE_NUMBER) {
                throw new BadArgumentException(
                    `relativeRingSizes : portions must add up to 1, but sum was ${portionsSum}`);
            }

            if (relativeSizes.length !== numberOfRings) {
                throw new BadArgumentException(
                    `relativeRingSizes : number of values must match number of rings (${
                        numberOfRings}) but was ${relativeSizes.length}`);
            }
        }
        return {
            partitionDy: () => 1,
            scaleInnerRadius: d => this._scaleRadius(d.data.path.length - 1, d.y0),
            scaleOuterRadius: d => this._scaleRadius(d.data.path.length, d.y1),
            relativeRingSizesFunction: ringCount => {
                const 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 SunburstChart#defaultRingSizes `defaultRingSizes`}: the rings get narrower farther away from the center
     * * {@link SunburstChart#relativeRingSizes `relativeRingSizes`}: set the ring sizes as portions of 1
     * * {@link 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()))
     * @param {RingSizes} ringSizes
     * @returns {Object|SunburstChart}
     */
    ringSizes (ringSizes) {
        if (!arguments.length) {
            if (!this._ringSizes) {
                this._ringSizes = this.defaultRingSizes();
            }
            return this._ringSizes;
        }
        this._ringSizes = ringSizes;
        return this;
    }

    _buildArcs () {
        return arc()
            .startAngle(d => d.x0)
            .endAngle(d => d.x1)
            .innerRadius(d => this.ringSizes().scaleInnerRadius(d))
            .outerRadius(d => this.ringSizes().scaleOuterRadius(d));
    }

    _isSelectedSlice (d) {
        return this._isPathFiltered(d.path);
    }

    _isPathFiltered (path) {
        for (let i = 0; i < this.filters().length; i++) {
            const currentFilter = this.filters()[i];
            if (currentFilter.isFiltered(path)) {
                return true;
            }
        }
        return false;
    }

    // returns all filters that are a parent or child of the path
    _filtersForPath (path) {
        const pathFilter = filters.HierarchyFilter(path);
        const filtersList = [];
        for (let i = 0; i < this.filters().length; i++) {
            const currentFilter = this.filters()[i];
            if (currentFilter.isFiltered(path) || pathFilter.isFiltered(currentFilter)) {
                filtersList.push(currentFilter);
            }
        }
        return filtersList;
    }

    _doRedraw () {
        this._drawChart();
        return this;
    }

    _partitionNodes (data) {
        const getSortable = function (d) {
            return {'key': d.data.key, 'value': d.value};
        };
        const _hierarchy = hierarchy(data)
            .sum(d => d.children ? 0 : this._extendedValueAccessor(d))
            .sort((a, b) => ascending(this.ordering()(getSortable(a)), this.ordering()(getSortable(b))));

        const _partition = partition()
              .size([2 * Math.PI, this.ringSizes().partitionDy()]);

        _partition(_hierarchy);

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

        const relativeSizes = this.ringSizes().relativeRingSizesFunction(_hierarchy.height);

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

    _sliceTooSmall (d) {
        const angle = d.x1 - d.x0;
        return isNaN(angle) || angle < this._minAngleForLabel;
    }

    _sliceHasNoData (d) {
        return this._extendedValueAccessor(d) === 0;
    }

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

    _fill (d, i) {
        return this.getColor(d.data, i);
    }

    onClick (d) {
        if (this._g.attr('class') === this._emptyCssClass) {
            return;
        }

        // Must be better way to handle this, in legends we need to access `d.key`
        const path = d.path || d.key;
        const filter = filters.HierarchyFilter(path);

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

    _safeArc (_arc, d) {
        let path = _arc(d);
        if (path.indexOf('NaN') >= 0) {
            path = 'M0,0';
        }
        return path;
    }

    _labelPosition (d, _arc) {
        let centroid;
        if (this._externalLabelRadius) {
            centroid = arc()
                .outerRadius(this._radius + this._externalLabelRadius)
                .innerRadius(this._radius + 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.key) {
                select(this).classed('highlight', highlighted);
            }
        });
    }

    _tweenSlice (d, element) {
        let current = element._current;
        if (this._isOffCanvas(current)) {
            current = {x0: 0, x1: 0, y0: 0, y1: 0};
        }
        const tweenTarget = {
            x0: d.x0,
            x1: d.x1,
            y0: d.y0,
            y1: d.y1
        };
        const i = interpolate(current, tweenTarget);
        element._current = i(0);
        return t => this._safeArc(this._buildArcs(), Object.assign({}, d, i(t)));
    }
}

export const sunburstChart = (parent, chartGroup) => new SunburstChart(parent, chartGroup);