highcharts
Version:
JavaScript charting framework
563 lines (562 loc) • 22.5 kB
JavaScript
/* *
*
* (c) 2010-2025 Pawel Lysy Grzegorz Blachlinski
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import PU from '../PathUtilities.js';
const { getLinkPath } = PU;
import SeriesRegistry from '../../Core/Series/SeriesRegistry.js';
const { series: { prototype: seriesProto }, seriesTypes: { treemap: TreemapSeries, column: ColumnSeries } } = SeriesRegistry;
import SVGRenderer from '../../Core/Renderer/SVG/SVGRenderer.js';
const { prototype: { symbols } } = SVGRenderer;
import TreegraphNode from './TreegraphNode.js';
import TreegraphPoint from './TreegraphPoint.js';
import TU from '../TreeUtilities.js';
const { getLevelOptions, getNodeWidth } = TU;
import U from '../../Core/Utilities.js';
const { arrayMax, crisp, extend, merge, pick, relativeLength, splat } = U;
import TreegraphLink from './TreegraphLink.js';
import TreegraphLayout from './TreegraphLayout.js';
import TreegraphSeriesDefaults from './TreegraphSeriesDefaults.js';
import SVGElement from '../../Core/Renderer/SVG/SVGElement.js';
import TextPath from '../../Extensions/TextPath.js';
TextPath.compose(SVGElement);
/* *
*
* Class
*
* */
/**
* The Treegraph series type.
*
* @private
* @class
* @name Highcharts.seriesTypes.treegraph
*
* @augments Highcharts.Series
*/
class TreegraphSeries extends TreemapSeries {
constructor() {
/* *
*
* Static Properties
*
* */
super(...arguments);
this.nodeList = [];
this.links = [];
}
/* *
*
* Functions
*
* */
init() {
super.init.apply(this, arguments);
this.layoutAlgorythm = new TreegraphLayout();
// Register the link data labels in the label collector for overlap
// detection.
const series = this, collectors = this.chart.labelCollectors, collectorFunc = function () {
const linkLabels = [];
// Check links for overlap
if (series.options.dataLabels &&
!splat(series.options.dataLabels)[0].allowOverlap) {
for (const link of (series.links || [])) {
if (link.dataLabel) {
linkLabels.push(link.dataLabel);
}
}
}
return linkLabels;
};
// Only add the collector function if it is not present
if (!collectors.some((f) => f.name === 'collectorFunc')) {
collectors.push(collectorFunc);
}
}
/**
* Calculate `a` and `b` parameters of linear transformation, where
* `finalPosition = a * calculatedPosition + b`.
*
* @return {LayoutModifiers} `a` and `b` parameter for x and y direction.
*/
getLayoutModifiers() {
const chart = this.chart, series = this, plotSizeX = chart.plotSizeX, plotSizeY = chart.plotSizeY, columnCount = arrayMax(this.points.map((p) => p.node.xPosition));
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, maxXSize = 0, minXSize = 0, maxYSize = 0, minYSize = 0;
this.points.forEach((point) => {
// When fillSpace is on, stop the layout calculation when the hidden
// points are reached. (#19038)
if (this.options.fillSpace && !point.visible) {
return;
}
const node = point.node, level = series.mapOptionsToLevel[point.node.level] || {}, markerOptions = merge(this.options.marker, level.marker, point.options.marker), nodeWidth = markerOptions.width ?? getNodeWidth(this, columnCount), radius = relativeLength(markerOptions.radius || 0, Math.min(plotSizeX, plotSizeY)), symbol = markerOptions.symbol, nodeSizeY = (symbol === 'circle' || !markerOptions.height) ?
radius * 2 :
relativeLength(markerOptions.height, plotSizeY), nodeSizeX = symbol === 'circle' || !nodeWidth ?
radius * 2 :
relativeLength(nodeWidth, plotSizeX);
node.nodeSizeX = nodeSizeX;
node.nodeSizeY = nodeSizeY;
let lineWidth;
if (node.xPosition <= minX) {
minX = node.xPosition;
lineWidth = markerOptions.lineWidth || 0;
minXSize = Math.max(nodeSizeX + lineWidth, minXSize);
}
if (node.xPosition >= maxX) {
maxX = node.xPosition;
lineWidth = markerOptions.lineWidth || 0;
maxXSize = Math.max(nodeSizeX + lineWidth, maxXSize);
}
if (node.yPosition <= minY) {
minY = node.yPosition;
lineWidth = markerOptions.lineWidth || 0;
minYSize = Math.max(nodeSizeY + lineWidth, minYSize);
}
if (node.yPosition >= maxY) {
maxY = node.yPosition;
lineWidth = markerOptions.lineWidth || 0;
maxYSize = Math.max(nodeSizeY + lineWidth, maxYSize);
}
});
// Calculate the values of linear transformation, which will later be
// applied as `nodePosition = a * x + b` for each direction.
const ay = maxY === minY ?
1 :
(plotSizeY - (minYSize + maxYSize) / 2) / (maxY - minY), by = maxY === minY ? plotSizeY / 2 : -ay * minY + minYSize / 2, ax = maxX === minX ?
1 :
(plotSizeX - (maxXSize + maxXSize) / 2) / (maxX - minX), bx = maxX === minX ? plotSizeX / 2 : -ax * minX + minXSize / 2;
return { ax, bx, ay, by };
}
getLinks() {
const series = this;
const links = [];
this.data.forEach((point) => {
const levelOptions = series.mapOptionsToLevel[point.node.level || 0] || {};
if (point.node.parent) {
const pointOptions = merge(levelOptions, point.options);
if (!point.linkToParent || point.linkToParent.destroyed) {
const link = new series.LinkClass(series, pointOptions, void 0, point);
point.linkToParent = link;
}
else {
// #19552
point.collapsed = pick(point.collapsed, (this.mapOptionsToLevel[point.node.level] || {}).collapsed);
point.linkToParent.visible =
point.linkToParent.toNode.visible;
}
point.linkToParent.index = links.push(point.linkToParent) - 1;
}
else {
if (point.linkToParent) {
series.links.splice(point.linkToParent.index);
point.linkToParent.destroy();
delete point.linkToParent;
}
}
});
return links;
}
buildTree(id, index, level, list, parent) {
const point = this.points[index];
level = (point && point.level) || level;
return super.buildTree.call(this, id, index, level, list, parent);
}
markerAttribs() {
// The super Series.markerAttribs returns { width: NaN, height: NaN },
// so just disable this for now.
return {};
}
setCollapsedStatus(node, visibility) {
const point = node.point;
if (point) {
// Take the level options into account.
point.collapsed = pick(point.collapsed, (this.mapOptionsToLevel[node.level] || {}).collapsed);
point.visible = visibility;
visibility = visibility === false ? false : !point.collapsed;
}
node.children.forEach((childNode) => {
this.setCollapsedStatus(childNode, visibility);
});
}
drawTracker() {
ColumnSeries.prototype.drawTracker.apply(this, arguments);
ColumnSeries.prototype.drawTracker.call(this, this.links);
}
/**
* Run pre-translation by generating the nodeColumns.
* @private
*/
translate() {
const series = this, options = series.options;
// NOTE: updateRootId modifies series.
let rootId = TU.updateRootId(series), rootNode;
// Call prototype function
seriesProto.translate.call(series);
const tree = series.tree = series.getTree();
rootNode = series.nodeMap[rootId];
if (rootId !== '' && (!rootNode || !rootNode.children.length)) {
series.setRootNode('', false);
rootId = series.rootNode;
rootNode = series.nodeMap[rootId];
}
series.mapOptionsToLevel = getLevelOptions({
from: rootNode.level + 1,
levels: options.levels,
to: tree.height,
defaults: {
levelIsConstant: series.options.levelIsConstant,
colorByPoint: options.colorByPoint
}
});
this.setCollapsedStatus(tree, true);
series.links = series.getLinks();
series.setTreeValues(tree);
this.layoutAlgorythm.calculatePositions(series);
series.layoutModifier = this.getLayoutModifiers();
this.points.forEach((point) => {
this.translateNode(point);
});
this.points.forEach((point) => {
if (point.linkToParent) {
this.translateLink(point.linkToParent);
}
});
if (!options.colorByPoint) {
series.setColorRecursive(series.tree);
}
}
translateLink(link) {
const fromNode = link.fromNode, toNode = link.toNode, linkWidth = this.options.link?.lineWidth || 0, factor = pick(this.options.link?.curveFactor, 0.5), type = pick(link.options.link?.type, this.options.link?.type, 'default');
if (fromNode.shapeArgs && toNode.shapeArgs) {
const fromNodeWidth = (fromNode.shapeArgs.width || 0), inverted = this.chart.inverted, y1 = crisp((fromNode.shapeArgs.y || 0) +
(fromNode.shapeArgs.height || 0) / 2, linkWidth), y2 = crisp((toNode.shapeArgs.y || 0) +
(toNode.shapeArgs.height || 0) / 2, linkWidth);
let x1 = crisp((fromNode.shapeArgs.x || 0) + fromNodeWidth, linkWidth), x2 = crisp(toNode.shapeArgs.x || 0, linkWidth);
if (inverted) {
x1 -= fromNodeWidth;
x2 += (toNode.shapeArgs.width || 0);
}
const diff = toNode.node.xPosition - fromNode.node.xPosition;
link.shapeType = 'path';
const fullWidth = Math.abs(x2 - x1) + fromNodeWidth, width = (fullWidth / diff) - fromNodeWidth, offset = width * factor * (inverted ? -1 : 1);
const xMiddle = crisp((x2 + x1) / 2, linkWidth);
link.plotX = xMiddle;
link.plotY = y2;
link.shapeArgs = {
d: getLinkPath[type]({
x1,
y1,
x2,
y2,
width,
offset,
inverted,
parentVisible: toNode.visible,
radius: this.options.link?.radius
})
};
link.dlBox = {
x: (x1 + x2) / 2,
y: (y1 + y2) / 2,
height: linkWidth,
width: 0
};
link.tooltipPos = inverted ? [
(this.chart.plotSizeY || 0) - link.dlBox.y,
(this.chart.plotSizeX || 0) - link.dlBox.x
] : [
link.dlBox.x,
link.dlBox.y
];
}
}
/**
* Private method responsible for adjusting the dataLabel options for each
* node-point individually.
*/
drawNodeLabels(points) {
const series = this, mapOptionsToLevel = series.mapOptionsToLevel;
let options, level;
for (const point of points) {
level = mapOptionsToLevel[point.node.level];
// Set options to new object to avoid problems with scope
options = { style: {} };
// If options for level exists, include them as well
if (level && level.dataLabels) {
options = merge(options, level.dataLabels);
series.hasDataLabels = () => true;
}
// Set dataLabel width to the width of the point shape.
if (point.shapeArgs &&
series.options.dataLabels) {
const css = {};
let { width = 0, height = 0 } = point.shapeArgs;
if (series.chart.inverted) {
[width, height] = [height, width];
}
if (!splat(series.options.dataLabels)[0].style?.width) {
css.width = `${width}px`;
}
if (!splat(series.options.dataLabels)[0].style?.lineClamp) {
css.lineClamp = Math.floor(height / 16);
}
extend(options.style, css);
point.dataLabel?.css(css);
}
// Merge custom options with point options
point.dlOptions = merge(options, point.options.dataLabels);
}
seriesProto.drawDataLabels.call(this, points);
}
/**
* Override alignDataLabel so that position is always calculated and the
* label is faded in and out instead of hidden/shown when collapsing and
* expanding nodes.
*/
alignDataLabel(point, dataLabel) {
const visible = point.visible;
// Force position calculation and visibility
point.visible = true;
super.alignDataLabel.apply(this, arguments);
// Fade in or out
dataLabel.animate({
opacity: visible === false ? 0 : 1
}, void 0, function () {
// Hide data labels that belong to hidden points (#18891)
visible || dataLabel.hide();
});
// Reset
point.visible = visible;
}
/**
* Treegraph has two separate collecions of nodes and lines,
* render dataLabels for both sets.
*/
drawDataLabels() {
if (this.options.dataLabels) {
this.options.dataLabels = splat(this.options.dataLabels);
// Render node labels.
this.drawNodeLabels(this.points);
// Render link labels.
seriesProto.drawDataLabels.call(this, this.links);
}
}
destroy() {
// Links must also be destroyed.
if (this.links) {
for (const link of this.links) {
link.destroy();
}
this.links.length = 0;
}
return seriesProto.destroy.apply(this, arguments);
}
/**
* Return the presentational attributes.
* @private
*/
pointAttribs(point, state) {
const series = this, levelOptions = point &&
series.mapOptionsToLevel[point.node.level || 0] || {}, options = point && point.options, stateOptions = (levelOptions.states &&
levelOptions.states[state]) ||
{};
if (point) {
point.options.marker = merge(series.options.marker, levelOptions.marker, point.options.marker);
}
const linkColor = pick(stateOptions && stateOptions.link && stateOptions.link.color, options && options.link && options.link.color, levelOptions && levelOptions.link && levelOptions.link.color, series.options.link && series.options.link.color), linkLineWidth = pick(stateOptions && stateOptions.link &&
stateOptions.link.lineWidth, options && options.link && options.link.lineWidth, levelOptions && levelOptions.link &&
levelOptions.link.lineWidth, series.options.link && series.options.link.lineWidth), attribs = seriesProto.pointAttribs.call(series, point, state);
if (point) {
if (point.isLink) {
attribs.stroke = linkColor;
attribs['stroke-width'] = linkLineWidth;
delete attribs.fill;
}
if (!point.visible) {
attribs.opacity = 0;
}
}
return attribs;
}
drawPoints() {
TreemapSeries.prototype.drawPoints.apply(this, arguments);
ColumnSeries.prototype.drawPoints.call(this, this.links);
}
/**
* Run translation operations for one node.
* @private
*/
translateNode(point) {
const chart = this.chart, node = point.node, plotSizeY = chart.plotSizeY, plotSizeX = chart.plotSizeX,
// Get the layout modifiers which are common for all nodes.
{ ax, bx, ay, by } = this.layoutModifier, x = ax * node.xPosition + bx, y = ay * node.yPosition + by, level = this.mapOptionsToLevel[node.level] || {}, markerOptions = merge(this.options.marker, level.marker, point.options.marker), symbol = markerOptions.symbol, height = node.nodeSizeY, width = node.nodeSizeX, reversed = this.options.reversed, nodeX = node.x = (chart.inverted ?
plotSizeX - width / 2 - x :
x - width / 2), nodeY = node.y = (!reversed ?
plotSizeY - y - height / 2 :
y - height / 2), borderRadius = pick(point.options.borderRadius, level.borderRadius, this.options.borderRadius), symbolFn = symbols[symbol || 'circle'];
if (symbolFn === void 0) {
point.hasImage = true;
point.shapeType = 'image';
point.imageUrl = symbol.match(/^url\((.*?)\)$/)[1];
}
else {
point.shapeType = 'path';
}
if (!point.visible && point.linkToParent) {
const parentNode = point.linkToParent.fromNode;
if (parentNode) {
const parentShapeArgs = parentNode.shapeArgs || {}, { x = 0, y = 0, width = 0, height = 0 } = parentShapeArgs;
if (!point.shapeArgs) {
point.shapeArgs = {};
}
if (!point.hasImage) {
extend(point.shapeArgs, {
d: symbolFn(x, y, width, height, borderRadius ? { r: borderRadius } : void 0)
});
}
extend(point.shapeArgs, { x, y });
point.plotX = parentNode.plotX;
point.plotY = parentNode.plotY;
}
}
else {
point.plotX = nodeX;
point.plotY = nodeY;
point.shapeArgs = {
x: nodeX,
y: nodeY,
width,
height,
cursor: !point.node.isLeaf ? 'pointer' : 'default'
};
if (!point.hasImage) {
point.shapeArgs.d = symbolFn(nodeX, nodeY, width, height, borderRadius ? { r: borderRadius } : void 0);
}
}
// Set the anchor position for tooltip.
point.tooltipPos = chart.inverted ?
[plotSizeY - nodeY - height / 2, plotSizeX - nodeX - width / 2] :
[nodeX + width / 2, nodeY];
}
}
TreegraphSeries.defaultOptions = merge(TreemapSeries.defaultOptions, TreegraphSeriesDefaults);
extend(TreegraphSeries.prototype, {
forceDL: true,
pointClass: TreegraphPoint,
NodeClass: TreegraphNode,
LinkClass: TreegraphLink,
isCartesian: false
});
SeriesRegistry.registerSeriesType('treegraph', TreegraphSeries);
/* *
*
* Default Export
*
* */
export default TreegraphSeries;
/* *
*
* API Options
*
* */
/**
* A `treegraph` series. If the [type](#series.treegraph.type)
* option is not specified, it is inherited from [chart.type](#chart.type).
*
* @extends series,plotOptions.treegraph
* @exclude allowDrillToNode, boostBlending, boostThreshold, curveFactor,
* centerInCategory, connectEnds, connectNulls, colorAxis, colorKey,
* dataSorting, dragDrop, findNearestPointBy, getExtremesFromAll, groupPadding,
* headers, layout, nodePadding, nodeSizeBy, pointInterval, pointIntervalUnit,
* pointPlacement, pointStart, relativeXValue, softThreshold, stack, stacking,
* step, traverseUpButton, xAxis, yAxis, zoneAxis, zones
* @product highcharts
* @requires modules/treemap
* @requires modules/treegraph
* @apioption series.treegraph
*/
/**
* @extends plotOptions.series.marker
* @excluding enabled, enabledThreshold
* @apioption series.treegraph.marker
*/
/**
* @type {Highcharts.SeriesTreegraphDataLabelsOptionsObject|Array<Highcharts.SeriesTreegraphDataLabelsOptionsObject>}
* @product highcharts
* @apioption series.treegraph.data.dataLabels
*/
/**
* @sample highcharts/series-treegraph/level-options
* Treegraph chart with level options applied
*
* @type {Array<*>}
* @excluding layoutStartingDirection, layoutAlgorithm
* @apioption series.treegraph.levels
*/
/**
* Set collapsed status for nodes level-wise.
* @type {boolean}
* @apioption series.treegraph.levels.collapsed
*/
/**
* Set marker options for nodes at the level.
* @extends series.treegraph.marker
* @apioption series.treegraph.levels.marker
*/
/**
* An array of data points for the series. For the `treegraph` series type,
* points can be given in the following ways:
*
* 1. The array of arrays, with `keys` property, which defines how the fields in
* array should be interpreted
* ```js
* keys: ['id', 'parent'],
* data: [
* ['Category1'],
* ['Category1', 'Category2']
* ]
*
* 2. An array of objects with named values. The following snippet shows only a
* few settings, see the complete options set below. If the total number of
* data points exceeds the
* series' [turboThreshold](#series.area.turboThreshold),
* this option is not available.
* The data of the treegraph series needs to be formatted in such a way, that
* there are no circular dependencies on the nodes
*
* ```js
* data: [{
* id: 'Category1'
* }, {
* id: 'Category1',
* parent: 'Category2',
* }]
* ```
*
* @type {Array<*>}
* @extends series.treemap.data
* @product highcharts
* @excluding outgoing, weight, value
* @apioption series.treegraph.data
*/
/**
* Options used for button, which toggles the collapse status of the node.
*
*
* @apioption series.treegraph.data.collapseButton
*/
/**
* If point's children should be initially hidden
*
* @sample highcharts/series-treegraph/level-options
* Treegraph chart with initially hidden children
*
* @type {boolean}
* @apioption series.treegraph.data.collapsed
*/
''; // Gets doclets above into transpiled version