@syncfusion/ej2-charts
Version:
Feature-rich chart control with built-in support for over 25 chart types, technical indictors, trendline, zooming, tooltip, selection, crosshair and trackball.
926 lines • 58 kB
JavaScript
import { Rect, measureText } from '@syncfusion/ej2-svg-base';
import { RectOption, appendChildElement, isCollide, getAnimationFunction } from '../../common/utils/helper';
import { Animation, isNullOrUndefined } from '@syncfusion/ej2-base';
import { getNodeColor } from '../model/sankey-theme';
var SankeySeries = /** @class */ (function () {
/**
* Constructor.
*
* @param {Sankey} chart - Sankey chart instance.
*/
function SankeySeries(chart) {
this.dataLabelRects = [];
this.chart = chart;
}
/**
* Renders the Sankey diagram by computing layout, creating clip path, and drawing links, nodes, and data labels.
*
* @param {Sankey} chart - The Sankey chart instance used for rendering.
* @returns {void}
*
* @private
*/
SankeySeries.prototype.render = function (chart) {
if (!chart.svgObject) {
return;
}
// Process data source if available, otherwise use links array
var sankeyLinks = chart.links;
if (!sankeyLinks.length) {
return;
}
var clipRect = chart.initialClipRect;
if (!clipRect) {
return;
}
this.nodeWidth = chart.nodeStyle.width;
this.nodePadding = chart.nodeStyle.padding;
this.linkOpacity = chart.linkStyle.opacity;
this.linkCurvature = chart.linkStyle.curvature;
this.chart = chart;
var nodeLevels = this.assignLevels(chart.nodeLayoutMap, sankeyLinks);
this.computeLayout(chart.nodeLayoutMap, nodeLevels, clipRect);
this.renderGroups(chart);
// Create a clipPath for Sankey rendering and apply to groups so we can animate reveal
var clipId = chart.element.id + '_sankey_clip';
var clipPathElement = chart.renderer.createClipPath({ id: clipId });
var clipRectOptions = new RectOption(clipId + '_rect', '', {}, 1, new Rect(clipRect.x - 10, clipRect.y - 10, clipRect.width + 20, chart.availableSize.height + 20));
var clipRectElement = chart.renderer.drawRectangle(clipRectOptions);
clipPathElement.appendChild(clipRectElement);
appendChildElement(false, chart.svgObject, clipPathElement, false);
this.renderLinks(chart, sankeyLinks, chart.nodeLayoutMap);
this.renderNodes(chart, chart.nodeLayoutMap);
this.renderDataLabels(chart, chart.nodeLayoutMap);
// Animate the clip rect to reveal Sankey elements
if (chart.animation.enable && chart.animateSeries && chart.animation.duration > 0) {
this.animateClipRect(clipRectElement, clipRect, chart);
chart.animateSeries = false;
}
};
/**
* Builds and returns a node layout map from the given links and user-defined node configuration.
*
* @param {SankeyLinkModel[]} links - Collection of Sankey links used to compute node in/out values.
* @param {Sankey} chart - The Sankey chart instance that provides theme and user node definitions.
* @returns {SankeyNodeLayout} returns node layout map for links and nodes.
*
* @private
*/
SankeySeries.prototype.buildNodes = function (links, chart) {
var nodeLayouts = {};
var colorPalette = getNodeColor(chart.theme);
var userDefinedNodes = Array.isArray(chart.nodes) ? chart.nodes : [];
var currentColorIndex = 0;
var getNextColor = function () {
var selectedColor = colorPalette[currentColorIndex % colorPalette.length];
currentColorIndex++;
return selectedColor;
};
var createNodeLayout = function (nodeId, nodeColor, nodeLabel) {
return {
id: nodeId,
value: 0,
inValue: 0,
outValue: 0,
level: 0,
x: 0,
y: 0,
height: 0,
outOffset: 0,
inOffset: 0,
color: nodeColor || getNextColor(),
label: nodeLabel
};
};
for (var _i = 0, userDefinedNodes_1 = userDefinedNodes; _i < userDefinedNodes_1.length; _i++) {
var userNode = userDefinedNodes_1[_i];
if (!userNode || !userNode.id) {
continue;
}
nodeLayouts[userNode.id] = createNodeLayout(userNode.id, userNode.color, userNode.label.text);
if (!isNullOrUndefined(userNode.offset)) {
nodeLayouts[userNode.id].offset = userNode.offset;
}
}
var getOrCreateNode = function (nodeId) {
if (!nodeLayouts[nodeId]) {
nodeLayouts[nodeId] = createNodeLayout(nodeId);
}
return nodeLayouts[nodeId];
};
for (var _a = 0, links_1 = links; _a < links_1.length; _a++) {
var link = links_1[_a];
var sourceNodeLayout = getOrCreateNode(link.sourceId);
var targetNodeLayout = getOrCreateNode(link.targetId);
sourceNodeLayout.outValue += link.value;
targetNodeLayout.inValue += link.value;
}
var nodeIds = Object.keys(nodeLayouts);
for (var index = 0; index < nodeIds.length; index++) {
var nodeLayout = nodeLayouts[nodeIds[index]];
nodeLayout.value = nodeLayout.inValue > nodeLayout.outValue ? nodeLayout.inValue : nodeLayout.outValue;
}
return nodeLayouts;
};
/**
* Assigns hierarchical levels to Sankey nodes using link direction and returns the total number of levels.
*
* @param {SankeyNodeLayout} nodes - Map of node id to its computed layout object.
* @param {SankeyLinkModel[]} links - Collection of links used to compute node levels.
* @returns {number} returns total number of levels.
*
* @private
*/
SankeySeries.prototype.assignLevels = function (nodes, links) {
var nodeInDegreeMap = {};
var nodeIds = Object.keys(nodes);
for (var nodeIndex = 0; nodeIndex < nodeIds.length; nodeIndex++) {
nodeInDegreeMap[nodeIds[nodeIndex]] = 0;
}
for (var _i = 0, links_2 = links; _i < links_2.length; _i++) {
var link = links_2[_i];
nodeInDegreeMap[link.targetId] = (nodeInDegreeMap[link.targetId] || 0) + 1;
}
var processingQueue = [];
var allNodeIds = Object.keys(nodeInDegreeMap);
for (var nodeIndex = 0; nodeIndex < allNodeIds.length; nodeIndex++) {
var nodeId = allNodeIds[nodeIndex];
if (nodeInDegreeMap[nodeId] === 0) {
processingQueue.push(nodeId);
}
}
var adjacencyList = {};
for (var _a = 0, links_3 = links; _a < links_3.length; _a++) {
var link = links_3[_a];
if (!adjacencyList[link.sourceId]) {
adjacencyList[link.sourceId] = [];
}
adjacencyList[link.sourceId].push(link.targetId);
}
var maximumLevel = 0;
while (processingQueue.length) {
var currentNodeId = processingQueue.shift();
var currentLevel = nodes[currentNodeId] ? nodes[currentNodeId].level : 0;
var childNodeIds = adjacencyList[currentNodeId] || [];
for (var childIndex = 0; childIndex < childNodeIds.length; childIndex++) {
var childNodeId = childNodeIds[childIndex];
var childNode = nodes[childNodeId];
if (childNode) {
childNode.level = childNode.level > (currentLevel + 1) ? childNode.level : (currentLevel + 1);
maximumLevel = maximumLevel > childNode.level ? maximumLevel : childNode.level;
}
nodeInDegreeMap[childNodeId] = (nodeInDegreeMap[childNodeId] || 0) - 1;
if (nodeInDegreeMap[childNodeId] === 0) {
processingQueue.push(childNodeId);
}
}
}
return maximumLevel + 1;
};
/**
* Computes node layout using vertical or horizontal strategy based on the chart orientation.
*
* @param {SankeyNodeLayout} nodes - Map of node id to its computed layout object.
* @param {number} levelCount - Total number of levels used to distribute nodes.
* @param {Rect} rect - The available clipping rectangle used for layout calculations.
* @returns {void}
*/
SankeySeries.prototype.computeLayout = function (nodes, levelCount, rect) {
var isVerticalLayout = this.chart && this.chart.orientation === 'Vertical';
if (isVerticalLayout) {
this.computeVerticalLayout(nodes, levelCount, rect);
}
else {
this.computeHorizontalLayout(nodes, levelCount, rect);
}
};
/**
* Computes the horizontal layout positions and sizes for nodes across levels within the given rectangle.
*
* @param {SankeyNodeLayout} nodes - Map of node id to its computed layout object.
* @param {number} levelCount - Total number of levels used to distribute nodes.
* @param {Rect} rect - The available clipping rectangle used for layout calculations.
* @returns {void}
*
* @private
*/
SankeySeries.prototype.computeHorizontalLayout = function (nodes, levelCount, rect) {
var horizontalGapBetweenLevels = (levelCount > 1) ? (rect.width - this.nodeWidth) / (levelCount - 1) : 0;
var nodesGroupedByLevel = {};
var nodeIds = Object.keys(nodes);
for (var nodeIndex = 0; nodeIndex < nodeIds.length; nodeIndex++) {
var currentNodeLayout = nodes[nodeIds[nodeIndex]];
if (currentNodeLayout) {
var nodesAtCurrentLevel = nodesGroupedByLevel[currentNodeLayout.level] || [];
nodesAtCurrentLevel.push(currentNodeLayout);
nodesGroupedByLevel[currentNodeLayout.level] = nodesAtCurrentLevel;
}
}
// Prepare global scaling: compute totals per level and use the largest-level total to derive a global scale.
var levelTotals = [];
var maxTotalValue = 0;
var maxNodesInLevel = 0;
for (var levelIndex = 0; levelIndex < levelCount; levelIndex++) {
var nodesInCurrentLevel = nodesGroupedByLevel[levelIndex] || [];
var totalValueInLevel = 0;
for (var nodeIndex = 0; nodeIndex < nodesInCurrentLevel.length; nodeIndex++) {
totalValueInLevel += nodesInCurrentLevel[nodeIndex].value;
}
levelTotals[levelIndex] = totalValueInLevel;
if (totalValueInLevel > maxTotalValue) {
maxTotalValue = totalValueInLevel;
}
if (nodesInCurrentLevel.length > maxNodesInLevel) {
maxNodesInLevel = nodesInCurrentLevel.length;
}
}
var maxTotalPadding = Math.max(maxNodesInLevel - 1, 0) * this.nodePadding;
var globalValueToHeightScale = maxTotalValue > 0 ? (rect.height - maxTotalPadding) / maxTotalValue : 0;
var _loop_1 = function (levelIndex) {
var nodesInCurrentLevel = nodesGroupedByLevel[levelIndex] || [];
var valueToHeightScale = globalValueToHeightScale;
// compute heights for this level
var nodeHeights = nodesInCurrentLevel.map(function (nodeLayout) {
return Math.max(1, nodeLayout.value * valueToHeightScale);
});
var totalNodeHeights = nodeHeights.reduce(function (sum, height) { return sum + height; }, 0);
var occupiedHeight = totalNodeHeights + Math.max(0, nodeHeights.length - 1) * this_1.nodePadding;
// center the level vertically inside rect
var startY = rect.y;
if (occupiedHeight < rect.height) {
startY = rect.y + (rect.height - occupiedHeight) / 2;
}
var currentYPosition = startY;
for (var nodeIndex = 0; nodeIndex < nodesInCurrentLevel.length; nodeIndex++) {
var currentNodeLayout = nodesInCurrentLevel[nodeIndex];
// Position nodes left-to-right normally; if RTL enabled, position right-to-left.
if (this_1.chart && this_1.chart.enableRtl) {
currentNodeLayout.x = rect.x + rect.width - (levelIndex * horizontalGapBetweenLevels) - this_1.nodeWidth;
}
else {
currentNodeLayout.x = rect.x + levelIndex * horizontalGapBetweenLevels;
}
currentNodeLayout.height = nodeHeights[nodeIndex];
currentNodeLayout.y = currentYPosition;
currentNodeLayout.outOffset = 0;
currentNodeLayout.inOffset = 0;
// Apply user-specified offset (pixels or percentage string)
if (!isNullOrUndefined(currentNodeLayout.offset)) {
currentNodeLayout.y += currentNodeLayout.offset;
}
currentYPosition += currentNodeLayout.height + this_1.nodePadding;
}
};
var this_1 = this;
for (var levelIndex = 0; levelIndex < levelCount; levelIndex++) {
_loop_1(levelIndex);
}
};
/**
* Computes the vertical layout positions and sizes for nodes across levels within the given rectangle.
*
* @param {SankeyNodeLayout} nodes - Map of node id to its computed layout object.
* @param {number} levelCount - Total number of levels used to distribute nodes.
* @param {Rect} rect - The available clipping rectangle used for layout calculations.
* @returns {void}
*
* @private
*/
SankeySeries.prototype.computeVerticalLayout = function (nodes, levelCount, rect) {
var verticalGapBetweenLevels = (levelCount > 1) ? (rect.height - this.nodeWidth) / (levelCount - 1) : 0;
var nodesGroupedByLevel = {};
var nodeIds = Object.keys(nodes);
// Group nodes by their level
for (var nodeIndex = 0; nodeIndex < nodeIds.length; nodeIndex++) {
var currentNodeLayout = nodes[nodeIds[nodeIndex]];
if (currentNodeLayout) {
var nodesAtCurrentLevel = nodesGroupedByLevel[currentNodeLayout.level] || [];
nodesAtCurrentLevel.push(currentNodeLayout);
nodesGroupedByLevel[currentNodeLayout.level] = nodesAtCurrentLevel;
}
}
// Position nodes for each level (vertical orientation). Use global scaling similar to horizontal mode
var levelTotalsVertical = [];
var maxTotalValueVertical = 0;
var maxNodesInLevelVertical = 0;
for (var levelIndex = 0; levelIndex < levelCount; levelIndex++) {
var nodesInCurrentLevel = nodesGroupedByLevel[levelIndex] || [];
var totalValueInLevel = 0;
for (var nodeIndex = 0; nodeIndex < nodesInCurrentLevel.length; nodeIndex++) {
totalValueInLevel += nodesInCurrentLevel[nodeIndex].value;
}
levelTotalsVertical[levelIndex] = totalValueInLevel;
if (totalValueInLevel > maxTotalValueVertical) {
maxTotalValueVertical = totalValueInLevel;
}
if (nodesInCurrentLevel.length > maxNodesInLevelVertical) {
maxNodesInLevelVertical = nodesInCurrentLevel.length;
}
}
var maxTotalPaddingV = Math.max(maxNodesInLevelVertical - 1, 0) * this.nodePadding;
var globalValueToWidthScale = maxTotalValueVertical > 0 ? (rect.width - maxTotalPaddingV) / maxTotalValueVertical : 0;
for (var levelIndex = 0; levelIndex < levelCount; levelIndex++) {
var nodesInCurrentLevel = nodesGroupedByLevel[levelIndex] || [];
var levelY = rect.y + levelIndex * verticalGapBetweenLevels;
// compute widths (stored in height) for this level
var nodeSpanWidths = nodesInCurrentLevel.map(function (nodeLayout) {
return Math.max(1, nodeLayout.value * globalValueToWidthScale);
});
var totalNodeWidths = nodeSpanWidths.reduce(function (sum, width) { return sum + width; }, 0);
var occupiedWidth = totalNodeWidths + Math.max(0, nodeSpanWidths.length - 1) * this.nodePadding;
// center horizontally inside rect
var startX = rect.x;
if (occupiedWidth < rect.width) {
startX = rect.x + (rect.width - occupiedWidth) / 2;
}
var currentXPosition = startX;
for (var nodeIndex = 0; nodeIndex < nodesInCurrentLevel.length; nodeIndex++) {
var currentNodeLayout = nodesInCurrentLevel[nodeIndex];
currentNodeLayout.y = levelY;
currentNodeLayout.x = currentXPosition;
currentNodeLayout.height = nodeSpanWidths[nodeIndex];
currentNodeLayout.outOffset = 0;
currentNodeLayout.inOffset = 0;
// Apply user-specified offset (pixels or percentage string) horizontally in vertical layout
if (!isNullOrUndefined(currentNodeLayout.offset)) {
currentNodeLayout.x += currentNodeLayout.offset;
}
currentXPosition += currentNodeLayout.height + this.nodePadding;
}
}
};
/**
* Creates and appends SVG groups for links, nodes, and labels with a clip-path applied.
*
* @param {Sankey} chart - The Sankey chart instance used to create and attach rendering groups.
* @returns {void}
*/
SankeySeries.prototype.renderGroups = function (chart) {
var chartElementId = chart.element.id;
var linkGroupId = chartElementId + '_link_collection';
var nodeGroupId = chartElementId + '_node_collection';
var labelGroupId = chartElementId + '_label_collection';
var clipId = chart.element.id + '_sankey_clip';
var existingLinkGroup = document.getElementById(linkGroupId);
if (existingLinkGroup && existingLinkGroup.parentNode) {
existingLinkGroup.parentNode.removeChild(existingLinkGroup);
}
var existingNodeGroup = document.getElementById(nodeGroupId);
if (existingNodeGroup && existingNodeGroup.parentNode) {
existingNodeGroup.parentNode.removeChild(existingNodeGroup);
}
var existingLabelGroup = document.getElementById(labelGroupId);
if (existingLabelGroup && existingLabelGroup.parentNode) {
existingLabelGroup.parentNode.removeChild(existingLabelGroup);
}
var svgRenderer = chart.renderer;
var linkCollectionGroup = svgRenderer.createGroup({ id: linkGroupId, 'clip-path': 'url(#' + clipId + ')' });
var nodeCollectionGroup = svgRenderer.createGroup({ id: nodeGroupId, 'clip-path': 'url(#' + clipId + ')' });
var labelCollectionGroup = svgRenderer.createGroup({ id: labelGroupId, 'clip-path': 'url(#' + clipId + ')' });
// Append nodes first so links draw on top of nodes, then labels on top
appendChildElement(false, chart.svgObject, nodeCollectionGroup, false);
appendChildElement(false, chart.svgObject, linkCollectionGroup, false);
appendChildElement(false, chart.svgObject, labelCollectionGroup, false);
};
/**
* Renders Sankey data labels near nodes with collision handling and label rendering event support.
*
* @param {Sankey} chart - The Sankey chart instance used to render node labels.
* @param {SankeyNodeLayout} nodes - Map of node id to its computed layout object.
* @returns {void} returns void.
*/
SankeySeries.prototype.renderDataLabels = function (chart, nodes) {
var labelSettings = chart.labelSettings;
if (!(labelSettings).visible) {
return;
}
var labelPadding;
var labelFontSize = String((labelSettings).fontSize);
var labelFontFamily = (labelSettings).fontFamily ? String((labelSettings).fontFamily) : '';
var labelFontWeight = String((labelSettings).fontWeight);
var labelFontStyle = String((labelSettings).fontStyle);
var defaultLabelColor = String((labelSettings).color ||
(chart.themeStyle && chart.themeStyle.datalabelFont && chart.themeStyle.datalabelFont.color) ||
((chart.theme && /Dark|HighContrast/i.test(String(chart.theme))) ? '#FFFFFF' : '#334155'));
// Store for use in label rendering event
var labelStyle = labelSettings;
// Build totals for each node: prefer inValue (total incoming), else outValue
var nodeTotals = {};
var nodeIds = Object.keys(nodes);
var maxLevel = -1;
for (var nodeIndex = 0; nodeIndex < nodeIds.length; nodeIndex++) {
var nodeId = nodeIds[nodeIndex];
var nodeLayout = nodes[nodeId];
nodeTotals[nodeId] = nodeLayout.inValue > 0 ? nodeLayout.inValue : nodeLayout.outValue;
if (nodeLayout.level > maxLevel) {
maxLevel = nodeLayout.level;
}
}
this.dataLabelRects = [];
// Ensure per-level label groups inside label collection
var labelCollection = document.getElementById(chart.element.id + '_label_collection');
labelCollection.setAttribute('aria-hidden', 'true');
var ensureLevelLabelGroup = function (level) {
var levelGroup = document.getElementById(chart.element.id + "_label_level_" + level + "_g");
if (!levelGroup) {
levelGroup = chart.renderer.createGroup({ id: chart.element.id + "_label_level_" + level + "_g" });
appendChildElement(false, labelCollection, levelGroup, false);
}
return levelGroup;
};
var isVertical = chart.orientation === 'Vertical';
// Render labels next to nodes
for (var nodeIndex = 0; nodeIndex < nodeIds.length; nodeIndex++) {
var nodeLayout = nodes[nodeIds[nodeIndex]];
var textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text');
// Use custom label if available, otherwise use node ID
var displayName = nodeLayout.label || nodeLayout.id;
var labelText = displayName + ' ' + (nodeTotals[nodeLayout.id]);
var labelColor = defaultLabelColor;
var eventNode = null;
var eventLink = null;
var userNodes = Array.isArray(chart.nodes) ? chart.nodes : [];
for (var userNodeIndex = 0; userNodeIndex < userNodes.length; userNodeIndex++) {
var userNode = userNodes[userNodeIndex];
if (userNode && userNode.id === nodeLayout.id) {
eventNode = userNode;
break;
}
}
var userLinks = Array.isArray(chart.links) ? chart.links : [];
for (var userLinkIndex = 0; userLinkIndex < userLinks.length; userLinkIndex++) {
var userLink = userLinks[userLinkIndex];
if (userLink && (userLink.sourceId === nodeLayout.id)) {
eventLink = userLink;
break;
}
}
// Prefer node-level label settings over chart-level labelSettings
var nodeLabelSettings = eventNode ? eventNode.label : null;
labelPadding = (nodeLabelSettings && !isNullOrUndefined(nodeLabelSettings.padding)) ? nodeLabelSettings.padding :
(labelSettings).padding;
var labelRenderArgs = {
text: labelText,
node: eventNode,
link: eventLink,
labelStyle: labelStyle
};
var beforeLabelRendering = labelText;
chart.trigger('labelRendering', labelRenderArgs);
if (beforeLabelRendering !== labelRenderArgs.text) {
nodeLayout.label = labelRenderArgs.text;
}
labelText = labelRenderArgs.text;
labelColor = labelRenderArgs.labelStyle.color || defaultLabelColor;
var levelGroup = ensureLevelLabelGroup(nodeLayout.level);
var childIndex = levelGroup.childElementCount;
textElement.setAttribute('id', chart.element.id + "_label_level_" + nodeLayout.level + "_" + childIndex);
textElement.setAttribute('fill', labelColor);
textElement.setAttribute('font-size', labelFontSize);
if (labelFontFamily) {
textElement.setAttribute('font-family', labelFontFamily);
}
if (labelFontWeight) {
textElement.setAttribute('font-weight', labelFontWeight);
}
if (labelFontStyle) {
textElement.setAttribute('font-style', labelFontStyle);
}
textElement.textContent = labelText;
var fontModel = {
size: labelFontSize,
fontFamily: labelFontFamily,
fontWeight: labelFontWeight,
fontStyle: labelFontStyle
};
var textSize = measureText(labelText, fontModel, chart.themeStyle.datalabelFont);
var labelWidth = textSize.width;
var labelHeight = textSize.height;
var xPos = void 0;
var yPos = void 0;
var rectX = void 0;
var rectY = void 0;
// Make isLastColumn available outside the branch so collision-shift can reuse the RTL-aware value
var isLastColumn = false;
if (isVertical) {
isLastColumn = nodeLayout.level === maxLevel;
var isLastRow = isLastColumn;
yPos = isLastRow ? (nodeLayout.y - labelPadding) : (nodeLayout.y + this.nodeWidth + labelPadding);
xPos = nodeLayout.x + nodeLayout.height / 2;
var anchor = isLastRow ? 'end' : 'start';
textElement.setAttribute('x', String(xPos));
textElement.setAttribute('y', String(yPos));
textElement.setAttribute('dominant-baseline', anchor === 'end' ? 'baseline' : 'hanging');
textElement.setAttribute('text-anchor', 'middle');
if (isLastRow) {
rectY = yPos - labelHeight;
}
else {
rectY = yPos;
}
rectX = xPos - labelWidth / 2;
}
else {
isLastColumn = isVertical ? (nodeLayout.level === maxLevel) : ((chart.enableRtl) ? (nodeLayout.level === 0)
: (nodeLayout.level === maxLevel));
xPos = isLastColumn ? (nodeLayout.x - labelPadding) : (nodeLayout.x + this.nodeWidth + labelPadding);
yPos = nodeLayout.y + nodeLayout.height / 2;
var anchor = isLastColumn ? 'end' : 'start';
// Flip start/end anchor semantics for RTL so text alignment matches direction
var anchorText = (chart && chart.enableRtl) ? (anchor === 'end' ? 'start' : (anchor === 'start' ? 'end' : anchor)) : anchor;
textElement.setAttribute('x', String(xPos));
textElement.setAttribute('y', String(yPos));
textElement.setAttribute('dominant-baseline', 'middle');
textElement.setAttribute('text-anchor', anchorText);
if (isLastColumn) {
rectX = xPos - labelWidth;
}
else {
rectX = xPos;
}
rectY = yPos - labelHeight / 2;
}
var labelRect = new Rect(rectX, rectY, labelWidth, labelHeight);
var tries = 0;
var maxTries = 6;
var step = 10;
while (isCollide(labelRect, this.dataLabelRects, { x: 0, y: 0, width: 0, height: 0 }) && tries < maxTries) {
tries++;
if (isVertical) {
labelRect.y += (nodeLayout.level === maxLevel) ? -step : step;
yPos += (nodeLayout.level === maxLevel) ? -step : step;
textElement.setAttribute('y', String(yPos));
}
else {
// use the RTL-aware isLastColumn (declared above) when shifting to avoid LTR-only logic
labelRect.x += isLastColumn ? -step : step;
xPos += isLastColumn ? -step : step;
textElement.setAttribute('x', String(xPos));
}
}
if (!isCollide(labelRect, this.dataLabelRects, { x: 0, y: 0, width: 0, height: 0 })) {
levelGroup.appendChild(textElement);
this.dataLabelRects.push(labelRect);
}
}
};
/**
* Renders Sankey node rectangles into level-based SVG groups using configured node styles and rendering events.
*
* @param {Sankey} chart - The Sankey chart instance used for rendering nodes.
* @param {SankeyNodeLayout} nodes - Map of node id to its computed layout object.
* @returns {void} returns void.
*
* @private
*/
SankeySeries.prototype.renderNodes = function (chart, nodes) {
var nodeCollectionGroup = document.getElementById(chart.element.id + '_node_collection');
if (!nodeCollectionGroup) {
return;
}
var nodeStyle = chart.nodeStyle ? chart.nodeStyle : {};
var nodeStroke = nodeStyle.stroke;
var nodeStrokeWidth = typeof nodeStyle.strokeWidth === 'number' ? nodeStyle.strokeWidth : 1;
var nodeOpacity = typeof nodeStyle.opacity === 'number' ? nodeStyle.opacity : 1;
var nodeFill = nodeStyle.fill;
var isVertical = chart.orientation === 'Vertical';
var nodeIds = Object.keys(nodes);
for (var nodeIndex = 0; nodeIndex < nodeIds.length; nodeIndex++) {
var currentNodeLayout = nodes[nodeIds[nodeIndex]];
// ensure level group exists
var levelGroupId = chart.element.id + "_node_level_" + currentNodeLayout.level + "_g";
var levelGroup = document.getElementById(levelGroupId);
if (!levelGroup) {
levelGroup = chart.renderer.createGroup({ id: levelGroupId });
appendChildElement(false, nodeCollectionGroup, levelGroup, false);
}
var fillColor = nodeFill || currentNodeLayout.color;
// Trigger nodeRendering event
var eventNode = null;
var userNodes = chart.nodes || [];
for (var userNodeIndex = 0; userNodeIndex < userNodes.length; userNodeIndex++) {
var userNode = userNodes[userNodeIndex];
if (userNode && userNode.id === currentNodeLayout.id) {
eventNode = userNode;
break;
}
}
var nodeRenderArgs = {
node: eventNode,
fill: fillColor
};
chart.trigger('nodeRendering', nodeRenderArgs);
fillColor = nodeRenderArgs.fill;
var nodeWidth = this.nodeWidth;
var nodeHeight = currentNodeLayout.height;
if (isVertical) {
nodeWidth = currentNodeLayout.height;
nodeHeight = this.nodeWidth;
}
var nodeRectOptions = new RectOption('', fillColor, { color: nodeStroke, width: nodeStrokeWidth }, 1, new Rect(currentNodeLayout.x, currentNodeLayout.y, nodeWidth, nodeHeight));
var nodeRectElement = chart.renderer.drawRectangle(nodeRectOptions);
// assign level-based sequential id: <chartId>_node_level_<level>_<index>
var levelChildCount = levelGroup.childElementCount;
var nodeElementId = chart.element.id + "_node_level_" + currentNodeLayout.level + "_" + levelChildCount;
nodeRectElement.setAttribute('id', nodeElementId);
nodeRectElement.setAttribute('aria-label', currentNodeLayout.id);
nodeRectElement.setAttribute('role', 'region');
nodeRectElement.setAttribute('tabindex', '-1'); // Readable, non-focusable
nodeRectElement.setAttribute('aria-hidden', 'false');
// Apply opacity
if (nodeOpacity < 1) {
nodeRectElement.setAttribute('opacity', nodeOpacity.toString());
}
appendChildElement(false, levelGroup, nodeRectElement, false);
}
};
/**
* Renders Sankey links into the link collection group with ordering, styling, and link rendering event support.
*
* @param {Sankey} chart - The Sankey chart instance used for rendering links.
* @param {SankeyLinkModel[]} links - Collection of link models used to create rendered link paths.
* @param { SankeyNodeLayout } nodes - Map of node id to its computed layout object.
* @returns {void}
*
* @private
*/
SankeySeries.prototype.renderLinks = function (chart, links, nodes) {
var _this = this;
var linkCollectionGroup = document.getElementById(chart.element.id + '_link_collection');
if (!linkCollectionGroup) {
return;
}
var linkStyle = chart.linkStyle;
var effectiveLinkOpacity = typeof linkStyle.opacity === 'number' ? linkStyle.opacity : this.linkOpacity;
var isVertical = chart.orientation === 'Vertical';
var gapBetweenLevels = isVertical ?
this.getVerticalGapBetweenLevels(nodes, chart.initialClipRect) :
this.getHorizontalGapBetweenLevels(nodes, chart.initialClipRect);
// Sort links to produce tidy stacking
var sortedLinks = links.slice().sort(function (linkA, linkB) {
var sourceA = nodes[linkA.sourceId];
var sourceB = nodes[linkB.sourceId];
var targetA = nodes[linkA.targetId];
var targetB = nodes[linkB.targetId];
if (isVertical) {
var levelA = sourceA ? sourceA.level : 0;
var levelB = sourceB ? sourceB.level : 0;
if (levelA !== levelB) {
return levelA - levelB;
}
var sourceXDiff = (sourceA ? sourceA.x : 0) - (sourceB ? sourceB.x : 0);
if (sourceXDiff !== 0) {
return sourceXDiff;
}
return (targetA ? targetA.x : 0) - (targetB ? targetB.x : 0);
}
else {
var isrtl = !!(_this.chart && _this.chart.enableRtl);
var sourceXA = sourceA ? sourceA.x : 0;
var sourceXB = sourceB ? sourceB.x : 0;
var primary = isrtl ? (sourceXB - sourceXA) : (sourceXA - sourceXB);
if (primary !== 0) {
return primary;
}
var targetYA = targetA ? targetA.y : 0;
var targetYB = targetB ? targetB.y : 0;
return targetYA - targetYB;
}
});
var userLinks = chart.links || [];
var colorType = chart.linkStyle && (chart.linkStyle).colorType;
for (var linkIndex = 0; linkIndex < sortedLinks.length; linkIndex++) {
var currentLink = sortedLinks[linkIndex];
var sourceNodeLayout = nodes[currentLink.sourceId];
var targetNodeLayout = nodes[currentLink.targetId];
if (!sourceNodeLayout || !targetNodeLayout) {
continue;
}
// Find matching user link for event payload (if any)
var eventLink = null;
for (var j = 0; j < userLinks.length; j++) {
var ul = userLinks[j];
if (ul && ul.sourceId === currentLink.sourceId && ul.targetId === currentLink.targetId) {
eventLink = ul;
break;
}
}
// Determine default link color based on configured colorType: Blend, Source, or Target
var defaultLinkColor = void 0;
if (colorType === 'Target') {
defaultLinkColor = targetNodeLayout.color;
}
else if (colorType === 'Blend') {
// Use gradient id
var gradId = this.getOrCreateLinkGradient(chart, sourceNodeLayout.color, targetNodeLayout.color,
/* isHorizontal */ !isVertical,
/* isRtl */ !!chart.enableRtl,
/* index */ linkIndex, currentLink.sourceId, currentLink.targetId);
defaultLinkColor = gradId; // renderer will convert to url(#id)
}
else {
// 'Source' or fallback
defaultLinkColor = sourceNodeLayout.color;
}
var linkRenderArgs = {
// Prefer the user-facing link object; fall back to model if needed
link: (eventLink) || currentLink,
fill: defaultLinkColor
};
chart.trigger('linkRendering', linkRenderArgs);
var finalFill = (linkRenderArgs && linkRenderArgs.fill) ? linkRenderArgs.fill : defaultLinkColor;
if (isVertical) {
this.renderVerticalLink(linkCollectionGroup, currentLink, sourceNodeLayout, targetNodeLayout, gapBetweenLevels, effectiveLinkOpacity, linkIndex, finalFill);
}
else {
this.renderHorizontalLink(linkCollectionGroup, currentLink, sourceNodeLayout, targetNodeLayout, gapBetweenLevels, effectiveLinkOpacity, linkIndex, finalFill);
}
}
};
/**
* Renders a horizontal Sankey link path between source and target nodes and appends it into a level-based SVG group.
*
* @param {Element} linkCollectionGroup - The parent SVG group that holds all rendered link groups.
* @param {SankeyLinkModel} currentLink - The current link model used to compute the rendered path and metadata.
* @param {SankeyNodeLayout} sourceNode - The source node layout used to compute link start position and thickness.
* @param {SankeyNodeLayout} targetNode - The target node layout used to compute link end position and thickness.
* @param {number} gapBetweenLevels - The horizontal gap between node levels used for bezier curvature calculation.
* @param {number} linkOpacity - The opacity value applied to the rendered link path.
* @param {number} index - The link index used to generate a stable link key attribute.
* @param {string} fill - The fill colcor based on the selected theme style.
* @returns {void}
*
* @private
*/
SankeySeries.prototype.renderHorizontalLink = function (linkCollectionGroup, currentLink, sourceNode, targetNode, gapBetweenLevels, linkOpacity, index, fill) {
var outgoingLinkHeight = sourceNode.value > 0 ? (currentLink.value / sourceNode.value) * sourceNode.height : 0;
var incomingLinkHeight = targetNode.value > 0 ? (currentLink.value / targetNode.value) * targetNode.height : 0;
var linkHeight = Math.max(0, Math.min(outgoingLinkHeight, incomingLinkHeight));
var isRtl = this.chart && this.chart.enableRtl;
// In RTL mode: source connects from left, target connects to right
// In LTR mode: source connects from right, target connects to left
var sourceX = isRtl ? sourceNode.x : (sourceNode.x + this.nodeWidth);
var sourceY = sourceNode.y + sourceNode.outOffset + linkHeight / 2;
var targetX = isRtl ? (targetNode.x + this.nodeWidth) : targetNode.x;
var targetY = targetNode.y + targetNode.inOffset + linkHeight / 2;
sourceNode.outOffset += linkHeight;
targetNode.inOffset += linkHeight;
var curvatureOffset = gapBetweenLevels * this.linkCurvature;
var pointX = isRtl ? (sourceX - curvatureOffset) : (sourceX + curvatureOffset);
var pointY = isRtl ? (targetX + curvatureOffset) : (targetX - curvatureOffset);
// Build closed ribbon path (top edge -> target top -> target bottom -> source bottom -> close)
var sourceTop = (sourceY - linkHeight / 2) || 0;
var sourceBottom = (sourceY + linkHeight / 2) || 0;
var targetTop = (targetY - linkHeight / 2) || 0;
var targetBottom = (targetY + linkHeight / 2) || 0;
var pathD = 'M ' + sourceX + ' ' + sourceTop +
' C ' + pointX + ' ' + sourceTop + ', ' + pointY + ' ' + targetTop + ', ' + targetX + ' ' + targetTop +
' L ' + targetX + ' ' + targetBottom +
' C ' + pointY + ' ' + targetBottom + ', ' + pointX + ' ' + sourceBottom + ', ' + sourceX + ' ' + sourceBottom +
' Z';
var linkPathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path');
var levelGroupId = this.chart.element.id + "_link_level_" + sourceNode.level + "_g";
var levelGroup = document.getElementById(levelGroupId);
if (!levelGroup) {
levelGroup = this.chart.renderer.createGroup({ id: levelGroupId });
appendChildElement(false, linkCollectionGroup, levelGroup, false);
}
var levelChildCount = levelGroup.childElementCount;
var generatedLinkId = this.chart.element.id + "_link_level_" + sourceNode.level + "_" + levelChildCount;
linkPathElement.setAttribute('id', generatedLinkId);
linkPathElement.setAttribute('d', pathD);
// Use gradient URL if grad id provided, otherwise color fill
if (fill && fill.indexOf('gradient-') === 0) {
linkPathElement.setAttribute('fill', "url(#" + fill + ")");
linkPathElement.setAttribute('stroke', 'none');
linkPathElement.setAttribute('stroke-width', '0');
}
else if (fill && fill.indexOf('url(') === 0) {
linkPathElement.setAttribute('fill', fill);
linkPathElement.setAttribute('stroke', 'none');
linkPathElement.setAttribute('stroke-width', '0');
}
else {
linkPathElement.setAttribute('fill', fill || sourceNode.color);
linkPathElement.setAttribute('stroke', 'none');
linkPathElement.setAttribute('stroke-width', '0');
}
linkPathElement.setAttribute('opacity', linkOpacity ? linkOpacity.toString() : '1');
linkPathElement.setAttribute('data-source', currentLink.sourceId);
linkPathElement.setAttribute('data-target', currentLink.targetId);
linkPathElement.setAttribute('data-value', currentLink.value.toString());
linkPathElement.setAttribute('data-link-key', currentLink.sourceId + "__" + currentLink.targetId + "__" + index);
linkPathElement.setAttribute('role', 'link');
var linkDescription = this.chart.accessibility.accessibilityDescription ||
"From " + currentLink.sourceId + " to " + currentLink.targetId + ": Value " + currentLink.value;
linkPathElement.setAttribute('aria-label', linkDescription);
linkPathElement.setAttribute('tabindex', '-1');
appendChildElement(false, levelGroup, linkPathElement, false);
};
/**
* Renders a vertical Sankey link as a filled ribbon path between source and target nodes and appends it into a level-based SVG group.
*
* @param {Element} linkCollectionGroup - The parent SVG group that holds all rendered link groups.
* @param {SankeyLinkModel} currentLink - The current link model used to compute the rendered ribbon path and metadata.
* @param {SankeyNodeLayout} sourceNode - The source node layout used to compute ribbon start position and thickness.
* @param {SankeyNodeLayout} targetNode - The target node layout used to compute ribbon end position and thickness.
* @param {number} gapBetweenLevels - The vertical gap between node levels used for curvature calculation.
* @param {number} linkOpacity - The opacity value applied to the rendered ribbon path.
* @param {number} index - The link index used to generate a stable link key attribute.
* @param {string} fill - The fill color of link based on theme color selected.
* @returns {void}
*
* @private
*/
SankeySeries.prototype.renderVerticalLink = function (linkCollectionGroup, currentLink, sourceNode, targetNode, gapBetweenLevels, linkOpacity, index, fill) {
var outBandWidth = sourceNode.value > 0 ? (currentLink.value / sourceNode.value) * sourceNode.height : 0;
var inBandWidth = targetNode.value > 0 ? (currentLink.value / targetNode.value) * targetNode.height : 0;
var linkBaseWidt = Math.min(outBandWidth, inBandWidth);
// Clamp to remaining band on each node so the last link closes cleanly
var sourceRemainingWidth = Math.max(0, sourceNode.height - sourceNode.outOffset);
var targetRemainingWidth = Math.max(0, targetNode.height - targetNode.inOffset);
var linkWidth = Math.max(0, Math.min(linkBaseWidt, sourceRemainingWidth, targetRemainingWidth));
if (sourceRemainingWidth - linkWidth < 0.5 || targetRemainingWidth - linkWidth < 0.5) {
linkWidth = Math.min(sourceRemainingWidth, targetRemainingWidth);
}
// Left/right edges on source and target nodes (in vertical layout, node.height represents horizontal span)
var sourceLeftX = sourceNode.x + sourceNode.outOffset;
var sourceRightX = sourceLeftX + linkWidth;
var targetLeftX = targetNode.x + targetNode.inOffset;
var targetRightX = targetLeftX + linkWidth;
// Y coordinates: from bottom of source row to top of target row
var y1 = sourceNode.y + this.nodeWidth; // bottom of source node (row thickness = nodeWidth)
var y2 = targetNode.y; // top of target node
// Update offsets AFTER we compute this band
sourceNode.outOffset += linkWidth;
targetNode.inOffset += linkWidth;
// Curvature clamped to half the gap
var rawCurv = gapBetweenLevels * this.linkCurvature;
var vGap = Math.max(0, y2 - y1);
var curve = Math.min(rawCurv, Math.max(0, vGap / 2 - 1));
var c1Y = y1 + curve;
var c2Y = y2 - curve;
// Build closed ribbon path
var pathD = 'M ' + sourceLeftX + ' ' + y1 +
' C ' + sourceLeftX + ' ' + c1Y + ', ' + targetLeftX + ' ' + c2Y + ', ' + targetLeftX + ' ' + y2 +
' L ' + targetRightX + ' ' + y2 +
' C ' + targetRightX + ' ' + c2Y + ', ' + sourceRightX + ' ' + c1Y + ', ' + sourceRightX + ' ' + y1 +
' Z';
var linkPathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path');
var levelGroupId = this.chart.element.id + "_link_level_" + sourceNode.level + "_g";
var levelGroup = document.getElementById(levelGroupId);
if (!levelGroup) {
levelGroup = this.chart.renderer.createGroup({ id: levelGroupId });
appendChildElement(false, linkCollectionGroup, levelGroup, false);
}
var levelChildCount = levelGroup.childElementCount;
var generatedLinkId = this.chart.element.id + "_link_level_" + sourceNode.level + "_" + levelChildCount;
linkPathElement.setAttribute('id', generatedLinkId);
linkPathElement.setAttribute('d', pathD);
// Use gradient URL if grad id provided, otherwise color fill
if (fill && fill.indexOf('gradient-') === 0) {
linkPathElement.setAttribute('fill', "url(#" + fill + ")");
}
else if (fill && fill.indexOf('url(') === 0) {
linkPathElement.setAttribute('fill', fill);
}
else {
linkPathElement.setAttribute('fill', fill || sourceNode.color);
}
linkPathElement.setAttribute('opacity', linkOpacity.toString());
linkPathElement.setAttribute('data-source', currentLink.sourceId);
linkPathElement.setAttribute('data-target', currentLink.targetId);
linkPathElement.setAttribute('data-value', currentLink.value.toString());
linkPathElement.setAttribute('data-link-key', currentLink.sourceId + "__" + currentLink.targetId + "__" + index);
linkPathElement.setAttribute('role', 'link');
var linkDescription = this.chart.accessibility.accessibilityDescription ||
"From " + currentLink.sourceId + " to " + currentLink.targetId + ": Value " + currentLink.value;
linkPathElement.setAttribute('aria-label', linkDescription);
linkPathElement.setAttribute('tabindex', '-1');
appendChildElement(false, levelGroup, linkPathElement, false);
};
/**
* Calculates the horizontal gap between node levels based on the minimum and maximum levels present.
*
* @param { SankeyNodeLayout } nodes - Map of node id to its computed layout object.
* @param {Rect} rect - The available clipping rectangle used for gap calculation.
* @returns {number} returns gap in number
*
* @private
*/
SankeySeries.prototype.getHorizontalGapBetweenLevels = function (nodes, rect) {
var minimumLevel = 9007199254740991; // Number.MAX_SAFE_INTEGER alternative
var maximumLevel = -1;
var nodeIds = Object.keys(nodes);
for (var nodeIndex = 0; nodeIndex < nodeIds.length; nodeIndex++) {
var currentNodeLayout = nodes[nodeIds[nodeIndex]];
minimumLevel = minimumLevel < currentNodeLayout.level ? minimumLevel : currentNodeLayout.level;
maximumLevel = maximumLevel > currentNodeLayout.level ? maximumLevel : currentNodeLayout.level;
}
var totalLevels = (maximumLevel - minimumLevel > 0 ? (maximumLevel - minimumLevel) : 0) + 1;
return (totalLevels > 1) ? (rect.width - this.nodeWidth) / (totalLevels - 1) : rect.width;
};
/**
* Calculates the vertical gap between node levels based on the minimum and maximum levels present.
*
* @param {SankeyNodeLayout} nodes - Map of node id to its c