highcharts
Version:
JavaScript charting framework
334 lines (333 loc) • 13.6 kB
JavaScript
/* *
*
* Arc diagram module
*
* (c) 2021 Piotr Madej, Grzegorz Blachliński
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
;
import ArcDiagramPoint from './ArcDiagramPoint.js';
import ArcDiagramSeriesDefaults from './ArcDiagramSeriesDefaults.js';
import SankeyColumnComposition from '../Sankey/SankeyColumnComposition.js';
import Series from '../../Core/Series/Series.js';
import SeriesRegistry from '../../Core/Series/SeriesRegistry.js';
import SVGRenderer from '../../Core/Renderer/SVG/SVGRenderer.js';
import U from '../../Core/Utilities.js';
import SVGElement from '../../Core/Renderer/SVG/SVGElement.js';
import TextPath from '../../Extensions/TextPath.js';
TextPath.compose(SVGElement);
const { prototype: { symbols } } = SVGRenderer;
const { seriesTypes: { column: ColumnSeries, sankey: SankeySeries } } = SeriesRegistry;
const { crisp, extend, merge, pick, relativeLength } = U;
/* *
*
* Class
*
* */
/**
* @private
* @class
* @name Highcharts.seriesTypes.arcdiagram
*
* @augments Highcharts.seriesTypes.sankey
*/
class ArcDiagramSeries extends SankeySeries {
/* *
*
* Functions
*
* */
/**
* Create node columns by analyzing the nodes and the relations between
* incoming and outgoing links.
* @private
*/
createNodeColumns() {
const series = this, chart = series.chart,
// Column needs casting, to much methods required at the same time
column = SankeyColumnComposition.compose([], series);
column.sankeyColumn.maxLength = chart.inverted ?
chart.plotHeight : chart.plotWidth;
// Get the translation factor needed for each column to fill up the plot
// height
column.sankeyColumn.getTranslationFactor = (series) => {
const nodes = column.slice(), minLinkWidth = this.options.minLinkWidth || 0;
let skipPoint, factor = 0, i, radius, maxRadius = 0, scale = 1, additionalSpace = 0, remainingWidth = (chart.plotSizeX || 0) -
(series.options.marker &&
series.options.marker.lineWidth || 0) -
(column.length - 1) *
series.nodePadding;
// Because the minLinkWidth option doesn't obey the direct
// translation, we need to run translation iteratively, check node
// heights, remove those nodes affected by minLinkWidth, check
// again, etc.
while (column.length) {
factor = remainingWidth / column.sankeyColumn.sum();
skipPoint = false;
i = column.length;
while (i--) {
radius = (column[i].getSum()) * factor * scale;
const plotArea = Math.min(chart.plotHeight, chart.plotWidth);
if (radius > plotArea) {
scale = Math.min(plotArea / radius, scale);
}
else if (radius < minLinkWidth) {
column.splice(i, 1);
remainingWidth -= minLinkWidth;
radius = minLinkWidth;
skipPoint = true;
}
additionalSpace += radius * (1 - scale) / 2;
maxRadius = Math.max(maxRadius, radius);
}
if (!skipPoint) {
break;
}
}
// Re-insert original nodes
column.length = 0;
nodes.forEach((node) => {
node.scale = scale;
column.push(node);
});
column.sankeyColumn.maxRadius = maxRadius;
column.sankeyColumn.scale = scale;
column.sankeyColumn.additionalSpace = additionalSpace;
return factor;
};
column.sankeyColumn.offset = function (node, factor) {
const equalNodes = node.series.options.equalNodes, nodePadding = series.nodePadding, maxRadius = Math.min(chart.plotWidth, chart.plotHeight, (column.sankeyColumn.maxLength || 0) /
series.nodes.length - nodePadding);
let offset = column.sankeyColumn.additionalSpace || 0, totalNodeOffset;
for (let i = 0; i < column.length; i++) {
const sum = column[i].getSum() *
(column.sankeyColumn.scale || 0);
const width = equalNodes ?
maxRadius :
Math.max(sum * factor, series.options.minLinkWidth || 0);
if (sum) {
totalNodeOffset = width + nodePadding;
}
else {
// If node sum equals 0 nodePadding is missed #12453
totalNodeOffset = 0;
}
if (column[i] === node) {
return {
relativeLeft: offset + relativeLength(node.options.offset || 0, totalNodeOffset)
};
}
offset += totalNodeOffset;
}
};
// Add nodes directly to the column right after it's creation
series.nodes.forEach(function (node) {
node.column = 0;
column.push(node);
});
return [column];
}
/**
* Run translation operations for one link.
* @private
*/
translateLink(point) {
const series = this, fromNode = point.fromNode, toNode = point.toNode, chart = this.chart, translationFactor = series.translationFactor, pointOptions = point.options, seriesOptions = series.options, linkWeight = pick(pointOptions.linkWeight, seriesOptions.linkWeight, Math.max((point.weight || 0) *
translationFactor *
fromNode.scale, (series.options.minLinkWidth || 0))), centeredLinks = point.series.options.centeredLinks, nodeTop = fromNode.nodeY;
const getX = (node, fromOrTo) => {
const linkLeft = ((node.offset(point, fromOrTo) || 0) *
translationFactor);
const x = Math.min(node.nodeX + linkLeft,
// Prevent links from spilling below the node (#12014)
node.nodeX + (node.shapeArgs && node.shapeArgs.height || 0) - linkWeight);
return x;
};
let fromX = centeredLinks ?
fromNode.nodeX +
((fromNode.shapeArgs.height || 0) - linkWeight) / 2 :
getX(fromNode, 'linksFrom'), toX = centeredLinks ? toNode.nodeX +
((toNode.shapeArgs.height || 0) - linkWeight) / 2 :
getX(toNode, 'linksTo'), bottom = nodeTop;
if (fromX > toX) {
[fromX, toX] = [toX, fromX];
}
if (seriesOptions.reversed) {
[fromX, toX] = [toX, fromX];
bottom = (chart.plotSizeY || 0) - bottom;
}
point.shapeType = 'path';
point.linkBase = [
fromX,
fromX + linkWeight,
toX,
toX + linkWeight
];
const linkRadius = ((toX + linkWeight - fromX) / Math.abs(toX + linkWeight - fromX)) * pick(seriesOptions.linkRadius, Math.min(Math.abs(toX + linkWeight - fromX) / 2, fromNode.nodeY - Math.abs(linkWeight)));
point.shapeArgs = {
d: [
['M', fromX, bottom],
[
'A',
(toX + linkWeight - fromX) / 2,
linkRadius,
0,
0,
1,
toX + linkWeight,
bottom
],
['L', toX, bottom],
[
'A',
(toX - fromX - linkWeight) / 2,
linkRadius - linkWeight,
0,
0,
0,
fromX + linkWeight,
bottom
],
['Z']
]
};
point.dlBox = {
x: fromX + (toX - fromX) / 2,
y: bottom - linkRadius,
height: linkWeight,
width: 0
};
// And set the tooltip anchor in the middle
point.tooltipPos = chart.inverted ? [
(chart.plotSizeY || 0) - point.dlBox.y - linkWeight / 2,
(chart.plotSizeX || 0) - point.dlBox.x
] : [
point.dlBox.x,
point.dlBox.y + linkWeight / 2
];
// Pass test in drawPoints
point.y = point.plotY = 1;
point.x = point.plotX = 1;
if (!point.color) {
point.color = fromNode.color;
}
}
/**
* Run translation operations for one node.
* @private
*/
translateNode(node, column) {
const series = this, translationFactor = series.translationFactor, chart = series.chart, maxNodesLength = chart.inverted ?
chart.plotWidth : chart.plotHeight, options = series.options, maxRadius = Math.min(chart.plotWidth, chart.plotHeight, maxNodesLength / node.series.nodes.length - this.nodePadding), sum = node.getSum() * (column.sankeyColumn.scale || 0), equalNodes = options.equalNodes, nodeHeight = equalNodes ?
maxRadius :
Math.max(sum * translationFactor, this.options.minLinkWidth || 0), lineWidth = options.marker?.lineWidth || 0, nodeOffset = column.sankeyColumn.offset(node, translationFactor), fromNodeLeft = crisp(pick(nodeOffset && nodeOffset.absoluteLeft, ((column.sankeyColumn.left(translationFactor) || 0) +
(nodeOffset && nodeOffset.relativeLeft || 0))), lineWidth), markerOptions = merge(options.marker, node.options.marker), symbol = markerOptions.symbol, markerRadius = markerOptions.radius, top = parseInt(options.offset, 10) *
((chart.inverted ?
chart.plotWidth : chart.plotHeight) - (crisp(this.colDistance * (node.column || 0) +
(markerOptions.lineWidth || 0) / 2, lineWidth) +
(column.sankeyColumn.scale || 0) *
(column.sankeyColumn.maxRadius || 0) / 2)) / 100;
node.sum = sum;
// If node sum is 0, don’t render the rect #12453
if (sum) {
// Draw the node
node.nodeX = fromNodeLeft;
node.nodeY = top;
const x = fromNodeLeft, width = node.options.width || options.width || nodeHeight, height = node.options.height || options.height || nodeHeight;
let y = top;
if (options.reversed) {
y = (chart.plotSizeY || 0) - top;
if (chart.inverted) {
y = (chart.plotSizeY || 0) - top;
}
}
if (this.mapOptionsToLevel) {
// Calculate data label options for the point
node.dlOptions = SankeySeries.getDLOptions({
level: this.mapOptionsToLevel[node.level],
optionsPoint: node.options
});
}
// Pass test in drawPoints
node.plotX = 1;
node.plotY = 1;
// Set the anchor position for tooltips
node.tooltipPos = chart.inverted ? [
(chart.plotSizeY || 0) - y - height / 2,
(chart.plotSizeX || 0) - x - width / 2
] : [
x + width / 2,
y + height / 2
];
node.shapeType = 'path';
node.shapeArgs = {
d: symbols[symbol || 'circle'](x, y - (markerRadius || height) / 2, markerRadius || width, markerRadius || height),
width: markerRadius || width,
height: markerRadius || height
};
node.dlBox = {
x: x + width / 2,
y: y,
height: 0,
width: 0
};
}
else {
node.dlOptions = {
enabled: false
};
}
}
// Networkgraph has two separate collecions of nodes and lines, render
// dataLabels for both sets:
drawDataLabels() {
if (this.options.dataLabels) {
const textPath = this.options.dataLabels.textPath;
// Render node labels:
ColumnSeries.prototype.drawDataLabels.call(this, this.nodes);
// Render link labels:
this.options.dataLabels.textPath =
this.options.dataLabels.linkTextPath;
ColumnSeries.prototype.drawDataLabels.call(this, this.data);
// Restore nodes
this.options.dataLabels.textPath = textPath;
}
}
pointAttribs(point,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
state) {
if (point && point.isNode) {
const { ...attrs } = Series.prototype.pointAttribs
.apply(this, arguments);
return attrs;
}
return super.pointAttribs.apply(this, arguments);
}
markerAttribs(point) {
if (point.isNode) {
return super.markerAttribs.apply(this, arguments);
}
return {};
}
}
/* *
*
* Static Properties
*
* */
ArcDiagramSeries.defaultOptions = merge(SankeySeries.defaultOptions, ArcDiagramSeriesDefaults);
extend(ArcDiagramSeries.prototype, {
orderNodes: false
});
ArcDiagramSeries.prototype.pointClass = ArcDiagramPoint;
SeriesRegistry.registerSeriesType('arcdiagram', ArcDiagramSeries);
/* *
*
* Default Export
*
* */
export default ArcDiagramSeries;