/**
* 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);
};