highcharts
Version:
JavaScript charting framework
639 lines (638 loc) • 24.4 kB
JavaScript
/* *
*
* This module implements sunburst charts in Highcharts.
*
* (c) 2016-2024 Highsoft AS
*
* Authors: Jon Arild Nygard
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import CU from '../CenteredUtilities.js';
const { getCenter, getStartAndEndRadians } = CU;
import H from '../../Core/Globals.js';
const { noop } = H;
import SeriesRegistry from '../../Core/Series/SeriesRegistry.js';
const { column: ColumnSeries, treemap: TreemapSeries } = SeriesRegistry.seriesTypes;
import SunburstPoint from './SunburstPoint.js';
import SunburstUtilities from './SunburstUtilities.js';
import TU from '../TreeUtilities.js';
const { getColor, getLevelOptions, setTreeValues, updateRootId } = TU;
import U from '../../Core/Utilities.js';
import SunburstNode from './SunburstNode.js';
import SunburstSeriesDefaults from './SunburstSeriesDefaults.js';
const { defined, error, extend, fireEvent, isNumber, isObject, isString, merge, splat } = U;
import SVGElement from '../../Core/Renderer/SVG/SVGElement.js';
import TextPath from '../../Extensions/TextPath.js';
TextPath.compose(SVGElement);
/* *
*
* Constants
*
* */
const rad2deg = 180 / Math.PI;
/* *
*
* Functions
*
* */
/** @private */
function isBoolean(x) {
return typeof x === 'boolean';
}
/**
* Find a set of coordinates given a start coordinates, an angle, and a
* distance.
*
* @private
* @function getEndPoint
*
* @param {number} x
* Start coordinate x
*
* @param {number} y
* Start coordinate y
*
* @param {number} angle
* Angle in radians
*
* @param {number} distance
* Distance from start to end coordinates
*
* @return {Highcharts.SVGAttributes}
* Returns the end coordinates, x and y.
*/
const getEndPoint = function getEndPoint(x, y, angle, distance) {
return {
x: x + (Math.cos(angle) * distance),
y: y + (Math.sin(angle) * distance)
};
};
/** @private */
function getDlOptions(params) {
// Set options to new object to avoid problems with scope
const point = params.point, shape = isObject(params.shapeArgs) ? params.shapeArgs : {}, optionsPoint = (isObject(params.optionsPoint) ?
params.optionsPoint.dataLabels :
{}),
// The splat was used because levels dataLabels
// options doesn't work as an array
optionsLevel = splat(isObject(params.level) ?
params.level.dataLabels :
{})[0], options = merge({
style: {}
}, optionsLevel, optionsPoint), { innerArcLength = 0, outerArcLength = 0 } = point;
let rotationRad, rotation, rotationMode = options.rotationMode;
if (!isNumber(options.rotation)) {
if (rotationMode === 'auto' || rotationMode === 'circular') {
if (options.useHTML &&
rotationMode === 'circular') {
// Change rotationMode to 'auto' to avoid using text paths
// for HTML labels, see #18953
rotationMode = 'auto';
}
if (innerArcLength < 1 &&
outerArcLength > shape.radius) {
rotationRad = 0;
// Trigger setTextPath function to get textOutline etc.
if (point.dataLabelPath && rotationMode === 'circular') {
options.textPath = {
enabled: true
};
}
}
else if (innerArcLength > 1 &&
outerArcLength > 1.5 * shape.radius) {
if (rotationMode === 'circular') {
options.textPath = {
enabled: true,
attributes: {
dy: 5
}
};
}
else {
rotationMode = 'parallel';
}
}
else {
// Trigger the destroyTextPath function
if (point.dataLabel?.textPath &&
rotationMode === 'circular') {
options.textPath = {
enabled: false
};
}
rotationMode = 'perpendicular';
}
}
if (rotationMode !== 'auto' && rotationMode !== 'circular') {
if (point.dataLabel && point.dataLabel.textPath) {
options.textPath = {
enabled: false
};
}
rotationRad = (shape.end -
(shape.end - shape.start) / 2);
}
if (rotationMode === 'parallel') {
options.style.width = Math.min(shape.radius * 2.5, (outerArcLength + innerArcLength) / 2);
}
else {
if (!defined(options.style.width) &&
shape.radius) {
options.style.width = point.node.level === 1 ?
2 * shape.radius :
shape.radius;
}
}
if (rotationMode === 'perpendicular') {
// 16 is the inferred line height. We don't know the real line
// yet because the label is not rendered. A better approach for this
// would be to hide the label from the `alignDataLabel` function
// when the actual line height is known.
if (outerArcLength < 16) {
options.style.width = 1;
}
else {
options.style.lineClamp = Math.floor(innerArcLength / 16) || 1;
}
}
// Apply padding (#8515)
options.style.width = Math.max(options.style.width - 2 * (options.padding || 0), 1);
rotation = (rotationRad * rad2deg) % 180;
if (rotationMode === 'parallel') {
rotation -= 90;
}
// Prevent text from rotating upside down
if (rotation > 90) {
rotation -= 180;
}
else if (rotation < -90) {
rotation += 180;
}
options.rotation = rotation;
}
if (options.textPath) {
if (point.shapeExisting.innerR === 0 &&
options.textPath.enabled) {
// Enable rotation to render text
options.rotation = 0;
// Center dataLabel - disable textPath
options.textPath.enabled = false;
// Setting width and padding
options.style.width = Math.max((point.shapeExisting.r * 2) -
2 * (options.padding || 0), 1);
}
else if (point.dlOptions &&
point.dlOptions.textPath &&
!point.dlOptions.textPath.enabled &&
(rotationMode === 'circular')) {
// Bring dataLabel back if was a center dataLabel
options.textPath.enabled = true;
}
if (options.textPath.enabled) {
// Enable rotation to render text
options.rotation = 0;
// Setting width and padding
options.style.width = Math.max((point.outerArcLength +
point.innerArcLength) / 2 -
2 * (options.padding || 0), 1);
options.style.whiteSpace = 'nowrap';
}
}
return options;
}
/** @private */
function getAnimation(shape, params) {
const point = params.point, radians = params.radians, innerR = params.innerR, idRoot = params.idRoot, idPreviousRoot = params.idPreviousRoot, shapeExisting = params.shapeExisting, shapeRoot = params.shapeRoot, shapePreviousRoot = params.shapePreviousRoot, visible = params.visible;
let from = {}, to = {
end: shape.end,
start: shape.start,
innerR: shape.innerR,
r: shape.r,
x: shape.x,
y: shape.y
};
if (visible) {
// Animate points in
if (!point.graphic && shapePreviousRoot) {
if (idRoot === point.id) {
from = {
start: radians.start,
end: radians.end
};
}
else {
from = (shapePreviousRoot.end <= shape.start) ? {
start: radians.end,
end: radians.end
} : {
start: radians.start,
end: radians.start
};
}
// Animate from center and outwards.
from.innerR = from.r = innerR;
}
}
else {
// Animate points out
if (point.graphic) {
if (idPreviousRoot === point.id) {
to = {
innerR: innerR,
r: innerR
};
}
else if (shapeRoot) {
to = (shapeRoot.end <= shapeExisting.start) ?
{
innerR: innerR,
r: innerR,
start: radians.end,
end: radians.end
} : {
innerR: innerR,
r: innerR,
start: radians.start,
end: radians.start
};
}
}
}
return {
from: from,
to: to
};
}
/** @private */
function getDrillId(point, idRoot, mapIdToNode) {
const node = point.node;
let drillId, nodeRoot;
if (!node.isLeaf) {
// When it is the root node, the drillId should be set to parent.
if (idRoot === point.id) {
nodeRoot = mapIdToNode[idRoot];
drillId = nodeRoot.parent;
}
else {
drillId = point.id;
}
}
return drillId;
}
/** @private */
function cbSetTreeValuesBefore(node, options) {
const mapIdToNode = options.mapIdToNode, parent = node.parent, nodeParent = parent ? mapIdToNode[parent] : void 0, series = options.series, chart = series.chart, points = series.points, point = points[node.i], colors = series.options.colors || chart && chart.options.colors, colorInfo = getColor(node, {
colors: colors,
colorIndex: series.colorIndex,
index: options.index,
mapOptionsToLevel: options.mapOptionsToLevel,
parentColor: nodeParent && nodeParent.color,
parentColorIndex: nodeParent && nodeParent.colorIndex,
series: options.series,
siblings: options.siblings
});
node.color = colorInfo.color;
node.colorIndex = colorInfo.colorIndex;
if (point) {
point.color = node.color;
point.colorIndex = node.colorIndex;
// Set slicing on node, but avoid slicing the top node.
node.sliced = (node.id !== options.idRoot) ? point.sliced : false;
}
return node;
}
/* *
*
* Class
*
* */
class SunburstSeries extends TreemapSeries {
/* *
*
* Functions
*
* */
alignDataLabel(point, dataLabel, labelOptions) {
if (labelOptions.textPath && labelOptions.textPath.enabled) {
return;
}
return super.alignDataLabel.apply(this, arguments);
}
/**
* Animate the slices in. Similar to the animation of polar charts.
* @private
*/
animate(init) {
const chart = this.chart, center = [
chart.plotWidth / 2,
chart.plotHeight / 2
], plotLeft = chart.plotLeft, plotTop = chart.plotTop, group = this.group;
let attribs;
// Initialize the animation
if (init) {
// Scale down the group and place it in the center
attribs = {
translateX: center[0] + plotLeft,
translateY: center[1] + plotTop,
scaleX: 0.001, // #1499
scaleY: 0.001,
rotation: 10,
opacity: 0.01
};
group.attr(attribs);
// Run the animation
}
else {
attribs = {
translateX: plotLeft,
translateY: plotTop,
scaleX: 1,
scaleY: 1,
rotation: 0,
opacity: 1
};
group.animate(attribs, this.options.animation);
}
}
drawPoints() {
const series = this, mapOptionsToLevel = series.mapOptionsToLevel, shapeRoot = series.shapeRoot, group = series.group, hasRendered = series.hasRendered, idRoot = series.rootNode, idPreviousRoot = series.idPreviousRoot, nodeMap = series.nodeMap, nodePreviousRoot = nodeMap[idPreviousRoot], shapePreviousRoot = nodePreviousRoot && nodePreviousRoot.shapeArgs, points = series.points, radians = series.startAndEndRadians, chart = series.chart, optionsChart = chart && chart.options && chart.options.chart || {}, animation = (isBoolean(optionsChart.animation) ?
optionsChart.animation :
true), positions = series.center, center = {
x: positions[0],
y: positions[1]
}, innerR = positions[3] / 2, renderer = series.chart.renderer, hackDataLabelAnimation = !!(animation &&
hasRendered &&
idRoot !== idPreviousRoot &&
series.dataLabelsGroup);
let animateLabels, animateLabelsCalled = false, addedHack = false;
if (hackDataLabelAnimation) {
series.dataLabelsGroup.attr({ opacity: 0 });
animateLabels = function () {
const s = series;
animateLabelsCalled = true;
if (s.dataLabelsGroup) {
s.dataLabelsGroup.animate({
opacity: 1,
visibility: 'inherit'
});
}
};
}
for (const point of points) {
const node = point.node, level = mapOptionsToLevel[node.level], shapeExisting = (point.shapeExisting || {}), shape = node.shapeArgs || {}, visible = !!(node.visible && node.shapeArgs);
let animationInfo, onComplete;
// Border radius requires the border-radius.js module. Adding it
// here because the SunburstSeries is a mess and I can't find the
// regular shapeArgs. Usually shapeArgs are created in the series'
// `translate` function and then passed directly on to the renderer
// in the `drawPoints` function.
shape.borderRadius = series.options.borderRadius;
if (hasRendered && animation) {
animationInfo = getAnimation(shape, {
center: center,
point: point,
radians: radians,
innerR: innerR,
idRoot: idRoot,
idPreviousRoot: idPreviousRoot,
shapeExisting: shapeExisting,
shapeRoot: shapeRoot,
shapePreviousRoot: shapePreviousRoot,
visible: visible
});
}
else {
// When animation is disabled, attr is called from animation.
animationInfo = {
to: shape,
from: {}
};
}
extend(point, {
shapeExisting: shape, // Store for use in animation
tooltipPos: [shape.plotX, shape.plotY],
drillId: getDrillId(point, idRoot, nodeMap),
name: '' + (point.name || point.id || point.index),
plotX: shape.plotX, // Used for data label position
plotY: shape.plotY, // Used for data label position
value: node.val,
isInside: visible,
isNull: !visible // Used for dataLabels & point.draw
});
point.dlOptions = getDlOptions({
point: point,
level: level,
optionsPoint: point.options,
shapeArgs: shape
});
if (!addedHack && visible) {
addedHack = true;
onComplete = animateLabels;
}
point.draw({
animatableAttribs: animationInfo.to,
attribs: extend(animationInfo.from, (!chart.styledMode && series.pointAttribs(point, (point.selected && 'select')))),
onComplete: onComplete,
group: group,
renderer: renderer,
shapeType: 'arc',
shapeArgs: shape
});
}
// Draw data labels after points
// TODO draw labels one by one to avoid additional looping
if (hackDataLabelAnimation && addedHack) {
series.hasRendered = false;
series.options.dataLabels.defer = true;
ColumnSeries.prototype.drawDataLabels.call(series);
series.hasRendered = true;
// If animateLabels is called before labels were hidden, then call
// it again.
if (animateLabelsCalled) {
animateLabels();
}
}
else {
ColumnSeries.prototype.drawDataLabels.call(series);
}
series.idPreviousRoot = idRoot;
}
/**
* The layout algorithm for the levels.
* @private
*/
layoutAlgorithm(parent, children, options) {
let startAngle = parent.start;
const range = parent.end - startAngle, total = parent.val, x = parent.x, y = parent.y, radius = ((options &&
isObject(options.levelSize) &&
isNumber(options.levelSize.value)) ?
options.levelSize.value :
0), innerRadius = parent.r, outerRadius = innerRadius + radius, slicedOffset = options && isNumber(options.slicedOffset) ?
options.slicedOffset :
0;
return (children || []).reduce((arr, child) => {
const percentage = (1 / total) * child.val, radians = percentage * range, radiansCenter = startAngle + (radians / 2), offsetPosition = getEndPoint(x, y, radiansCenter, slicedOffset), values = {
x: child.sliced ? offsetPosition.x : x,
y: child.sliced ? offsetPosition.y : y,
innerR: innerRadius,
r: outerRadius,
radius: radius,
start: startAngle,
end: startAngle + radians
};
arr.push(values);
startAngle = values.end;
return arr;
}, []);
}
setRootNode(id, redraw, eventArguments) {
const series = this;
if ( // If the target node is the only one at level 1, skip it. (#18658)
series.nodeMap[id].level === 1 &&
series.nodeList
.filter((node) => node.level === 1)
.length === 1) {
if (series.idPreviousRoot === '') {
return;
}
id = '';
}
super.setRootNode(id, redraw, eventArguments);
}
/**
* Set the shape arguments on the nodes. Recursive from root down.
* @private
*/
setShapeArgs(parent, parentValues, mapOptionsToLevel) {
const level = parent.level + 1, options = mapOptionsToLevel[level],
// Collect all children which should be included
children = parent.children.filter(function (n) {
return n.visible;
}), twoPi = 6.28; // Two times Pi.
let childrenValues = [];
childrenValues = this.layoutAlgorithm(parentValues, children, options);
let i = -1;
for (const child of children) {
const values = childrenValues[++i], angle = values.start + ((values.end - values.start) / 2), radius = values.innerR + ((values.r - values.innerR) / 2), radians = (values.end - values.start), isCircle = (values.innerR === 0 && radians > twoPi), center = (isCircle ?
{ x: values.x, y: values.y } :
getEndPoint(values.x, values.y, angle, radius)), val = (child.val ?
(child.childrenTotal > child.val ?
child.childrenTotal :
child.val) :
child.childrenTotal);
// The inner arc length is a convenience for data label filters.
if (this.points[child.i]) {
this.points[child.i].innerArcLength = radians * values.innerR;
this.points[child.i].outerArcLength = radians * values.r;
}
child.shapeArgs = merge(values, {
plotX: center.x,
plotY: center.y
});
child.values = merge(values, {
val: val
});
// If node has children, then call method recursively
if (child.children.length) {
this.setShapeArgs(child, child.values, mapOptionsToLevel);
}
}
}
translate() {
const series = this, options = series.options, positions = series.center = series.getCenter(), radians = series.startAndEndRadians = getStartAndEndRadians(options.startAngle, options.endAngle), innerRadius = positions[3] / 2, outerRadius = positions[2] / 2, diffRadius = outerRadius - innerRadius,
// NOTE: updateRootId modifies series.
rootId = updateRootId(series);
let mapIdToNode = series.nodeMap, mapOptionsToLevel, nodeRoot = mapIdToNode && mapIdToNode[rootId], nodeIds = {};
series.shapeRoot = nodeRoot && nodeRoot.shapeArgs;
series.generatePoints();
fireEvent(series, 'afterTranslate');
// @todo Only if series.isDirtyData is true
const tree = series.tree = series.getTree();
// Render traverseUpButton, after series.nodeMap i calculated.
mapIdToNode = series.nodeMap;
nodeRoot = mapIdToNode[rootId];
const idTop = isString(nodeRoot.parent) ? nodeRoot.parent : '', nodeTop = mapIdToNode[idTop], { from, to } = SunburstUtilities.getLevelFromAndTo(nodeRoot);
mapOptionsToLevel = getLevelOptions({
from,
levels: series.options.levels,
to,
defaults: {
colorByPoint: options.colorByPoint,
dataLabels: options.dataLabels,
levelIsConstant: options.levelIsConstant,
levelSize: options.levelSize,
slicedOffset: options.slicedOffset
}
});
// NOTE consider doing calculateLevelSizes in a callback to
// getLevelOptions
mapOptionsToLevel = SunburstUtilities.calculateLevelSizes(mapOptionsToLevel, {
diffRadius,
from,
to
});
// TODO Try to combine setTreeValues & setColorRecursive to avoid
// unnecessary looping.
setTreeValues(tree, {
before: cbSetTreeValuesBefore,
idRoot: rootId,
levelIsConstant: options.levelIsConstant,
mapOptionsToLevel: mapOptionsToLevel,
mapIdToNode: mapIdToNode,
points: series.points,
series: series
});
const values = mapIdToNode[''].shapeArgs = {
end: radians.end,
r: innerRadius,
start: radians.start,
val: nodeRoot.val,
x: positions[0],
y: positions[1]
};
this.setShapeArgs(nodeTop, values, mapOptionsToLevel);
// Set mapOptionsToLevel on series for use in drawPoints.
series.mapOptionsToLevel = mapOptionsToLevel;
// #10669 - verify if all nodes have unique ids
for (const point of series.points) {
if (nodeIds[point.id]) {
error(31, false, series.chart);
}
// Map
nodeIds[point.id] = true;
}
// Reset object
nodeIds = {};
}
}
/* *
*
* Static Properties
*
* */
SunburstSeries.defaultOptions = merge(TreemapSeries.defaultOptions, SunburstSeriesDefaults);
extend(SunburstSeries.prototype, {
axisTypes: [],
drawDataLabels: noop, // `drawDataLabels` is called in `drawPoints`
getCenter: getCenter,
isCartesian: false,
// Mark that the sunburst is supported by the series on point feature.
onPointSupported: true,
pointAttribs: ColumnSeries.prototype.pointAttribs,
pointClass: SunburstPoint,
NodeClass: SunburstNode,
utils: SunburstUtilities
});
SeriesRegistry.registerSeriesType('sunburst', SunburstSeries);
/* *
*
* Default Export
*
* */
export default SunburstSeries;