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