highcharts
Version:
JavaScript charting framework
754 lines (753 loc) • 27.3 kB
JavaScript
/* *
*
* (c) 2016 Highsoft AS
* Authors: Jon Arild Nygard
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
;
import BrokenAxis from '../BrokenAxis.js';
import GridAxis from '../GridAxis.js';
import Tree from '../../../Gantt/Tree.js';
import TreeGridTick from './TreeGridTick.js';
import TU from '../../../Series/TreeUtilities.js';
const { getLevelOptions } = TU;
import U from '../../Utilities.js';
const { addEvent, isArray, splat, find, fireEvent, isObject, isString, merge, pick, removeEvent, wrap } = U;
/* *
*
* Variables
*
* */
let TickConstructor;
/* *
*
* Functions
*
* */
/**
* @private
*/
function getBreakFromNode(node, max) {
const to = node.collapseEnd || 0;
let from = node.collapseStart || 0;
// In broken-axis, the axis.max is minimized until it is not within a
// break. Therefore, if break.to is larger than axis.max, the axis.to
// should not add the 0.5 axis.tickMarkOffset, to avoid adding a break
// larger than axis.max.
// TODO consider simplifying broken-axis and this might solve itself
if (to >= max) {
from -= 0.5;
}
return {
from: from,
to: to,
showPoints: false
};
}
/**
* Creates a tree structure of the data, and the treegrid. Calculates
* categories, and y-values of points based on the tree.
*
* @private
* @function getTreeGridFromData
*
* @param {Array<Highcharts.GanttPointOptions>} data
* All the data points to display in the axis.
*
* @param {boolean} uniqueNames
* Whether or not the data node with the same name should share grid cell. If
* true they do share cell. False by default.
*
* @param {number} numberOfSeries
*
* @return {Object}
* Returns an object containing categories, mapOfIdToNode,
* mapOfPosToGridNode, and tree.
*
* @todo There should be only one point per line.
* @todo It should be optional to have one category per point, or merge
* cells
* @todo Add unit-tests.
*/
function getTreeGridFromData(data, uniqueNames, numberOfSeries) {
const categories = [], collapsedNodes = [], mapOfIdToNode = {}, uniqueNamesEnabled = uniqueNames || false;
let mapOfPosToGridNode = {}, posIterator = -1;
// Build the tree from the series data.
const treeParams = {
// After the children has been created.
after: function (node) {
const gridNode = mapOfPosToGridNode[node.pos];
let height = 0, descendants = 0;
gridNode.children.forEach(function (child) {
descendants += (child.descendants || 0) + 1;
height = Math.max((child.height || 0) + 1, height);
});
gridNode.descendants = descendants;
gridNode.height = height;
if (gridNode.collapsed) {
collapsedNodes.push(gridNode);
}
},
// Before the children has been created.
before: function (node) {
const data = isObject(node.data, true) ?
node.data :
{}, name = isString(data.name) ? data.name : '', parentNode = mapOfIdToNode[node.parent], parentGridNode = (isObject(parentNode, true) ?
mapOfPosToGridNode[parentNode.pos] :
null), hasSameName = function (x) {
return x.name === name;
};
let gridNode, pos;
// If not unique names, look for sibling node with the same name
if (uniqueNamesEnabled &&
isObject(parentGridNode, true) &&
!!(gridNode = find(parentGridNode.children, hasSameName))) {
// If there is a gridNode with the same name, reuse position
pos = gridNode.pos;
// Add data node to list of nodes in the grid node.
gridNode.nodes.push(node);
}
else {
// If it is a new grid node, increment position.
pos = posIterator++;
}
// Add new grid node to map.
if (!mapOfPosToGridNode[pos]) {
mapOfPosToGridNode[pos] = gridNode = {
depth: parentGridNode ? parentGridNode.depth + 1 : 0,
name: name,
id: data.id,
nodes: [node],
children: [],
pos: pos
};
// If not root, then add name to categories.
if (pos !== -1) {
categories.push(name);
}
// Add name to list of children.
if (isObject(parentGridNode, true)) {
parentGridNode.children.push(gridNode);
}
}
// Add data node to map
if (isString(node.id)) {
mapOfIdToNode[node.id] = node;
}
// If one of the points are collapsed, then start the grid node
// in collapsed state.
if (gridNode &&
data.collapsed === true) {
gridNode.collapsed = true;
}
// Assign pos to data node
node.pos = pos;
}
};
const updateYValuesAndTickPos = function (map, numberOfSeries) {
const setValues = function (gridNode, start, result) {
const nodes = gridNode.nodes, padding = 0.5;
let end = start + (start === -1 ? 0 : numberOfSeries - 1);
const diff = (end - start) / 2, pos = start + diff;
nodes.forEach(function (node) {
const data = node.data;
if (isObject(data, true)) {
// Update point
data.y = start + (data.seriesIndex || 0);
// Remove the property once used
delete data.seriesIndex;
}
node.pos = pos;
});
result[pos] = gridNode;
gridNode.pos = pos;
gridNode.tickmarkOffset = diff + padding;
gridNode.collapseStart = end + padding;
gridNode.children.forEach(function (child) {
setValues(child, end + 1, result);
end = (child.collapseEnd || 0) - padding;
});
// Set collapseEnd to the end of the last child node.
gridNode.collapseEnd = end + padding;
return result;
};
return setValues(map['-1'], -1, {});
};
// Create tree from data
const tree = Tree.getTree(data, treeParams);
// Update y values of data, and set calculate tick positions.
mapOfPosToGridNode = updateYValuesAndTickPos(mapOfPosToGridNode, numberOfSeries);
// Return the resulting data.
return {
categories: categories,
mapOfIdToNode: mapOfIdToNode,
mapOfPosToGridNode: mapOfPosToGridNode,
collapsedNodes: collapsedNodes,
tree: tree
};
}
/**
* Builds the tree of categories and calculates its positions.
* @private
* @param {Object} e Event object
* @param {Object} e.target The chart instance which the event was fired on.
* @param {object[]} e.target.axes The axes of the chart.
*/
function onBeforeRender(e) {
const chart = e.target, axes = chart.axes;
axes.filter((axis) => axis.type === 'treegrid').forEach(function (axis) {
const options = axis.options || {}, labelOptions = options.labels, uniqueNames = axis.uniqueNames, max = chart.time.parse(options.max),
// Check whether any of series is rendering for the first
// time, visibility has changed, or its data is dirty, and
// only then update. #10570, #10580. Also check if
// mapOfPosToGridNode exists. #10887
isDirty = (!axis.treeGrid.mapOfPosToGridNode ||
axis.series.some(function (series) {
return !series.hasRendered ||
series.isDirtyData ||
series.isDirty;
}));
let numberOfSeries = 0, data, treeGrid;
if (isDirty) {
const seriesHasPrimitivePoints = [];
// Concatenate data from all series assigned to this axis.
data = axis.series.reduce(function (arr, s) {
const seriesData = (s.options.data || []), firstPoint = seriesData[0],
// Check if the first point is a simple array of values.
// If so we assume that this is the case for all points.
foundPrimitivePoint = (Array.isArray(firstPoint) &&
!firstPoint.find((value) => (typeof value === 'object')));
seriesHasPrimitivePoints.push(foundPrimitivePoint);
if (s.visible) {
// Push all data to array
seriesData.forEach(function (pointOptions) {
// For using keys, or when using primitive points,
// rebuild the data structure
if (foundPrimitivePoint || s.options.keys?.length) {
pointOptions = s.pointClass.prototype
.optionsToObject
.call({ series: s }, pointOptions);
s.pointClass.setGanttPointAliases(pointOptions, chart);
}
if (isObject(pointOptions, true)) {
// Set series index on data. Removed again
// after use.
pointOptions.seriesIndex = (numberOfSeries);
arr.push(pointOptions);
}
});
// Increment series index
if (uniqueNames === true) {
numberOfSeries++;
}
}
return arr;
}, []);
// If max is higher than set data - add a
// dummy data to render categories #10779
if (max && data.length < max) {
for (let i = data.length; i <= max; i++) {
data.push({
// Use the zero-width character
// to avoid conflict with uniqueNames
name: i + '\u200B'
});
}
}
// `setScale` is fired after all the series is initialized,
// which is an ideal time to update the axis.categories.
treeGrid = getTreeGridFromData(data, uniqueNames || false, (uniqueNames === true) ? numberOfSeries : 1);
// Assign values to the axis.
axis.categories = treeGrid.categories;
axis.treeGrid.mapOfPosToGridNode = (treeGrid.mapOfPosToGridNode);
axis.hasNames = true;
axis.treeGrid.tree = treeGrid.tree;
// Update yData now that we have calculated the y values
axis.series.forEach(function (series, index) {
const axisData = (series.options.data || []).map(function (d) {
if (seriesHasPrimitivePoints[index] ||
(isArray(d) &&
series.options.keys &&
series.options.keys.length)) {
// Get the axisData from the data array used to
// build the treeGrid where has been modified
data.forEach(function (point) {
const toArray = splat(d);
if (toArray.indexOf(point.x || 0) >= 0 &&
toArray.indexOf(point.x2 || 0) >= 0) {
d = point;
}
});
}
return isObject(d, true) ? merge(d) : d;
});
// Avoid destroying points when series is not visible
if (series.visible) {
series.setData(axisData, false);
}
});
// Calculate the label options for each level in the tree.
axis.treeGrid.mapOptionsToLevel =
getLevelOptions({
defaults: labelOptions,
from: 1,
levels: labelOptions?.levels,
to: axis.treeGrid.tree?.height
});
// Setting initial collapsed nodes
if (e.type === 'beforeRender') {
axis.treeGrid.collapsedNodes = treeGrid.collapsedNodes;
}
}
});
}
/**
* Generates a tick for initial positioning.
*
* @private
* @function Highcharts.GridAxis#generateTick
*
* @param {Function} proceed
* The original generateTick function.
*
* @param {number} pos
* The tick position in axis values.
*/
function wrapGenerateTick(proceed, pos) {
const axis = this, mapOptionsToLevel = axis.treeGrid.mapOptionsToLevel || {}, isTreeGrid = axis.type === 'treegrid', ticks = axis.ticks;
let tick = ticks[pos], levelOptions, options, gridNode;
if (isTreeGrid &&
axis.treeGrid.mapOfPosToGridNode) {
gridNode = axis.treeGrid.mapOfPosToGridNode[pos];
levelOptions = mapOptionsToLevel[gridNode.depth];
if (levelOptions) {
options = {
labels: levelOptions
};
}
if (!tick &&
TickConstructor) {
ticks[pos] = tick =
new TickConstructor(axis, pos, void 0, void 0, {
category: gridNode.name,
tickmarkOffset: gridNode.tickmarkOffset,
options: options
});
}
else {
// Update labels depending on tick interval
tick.parameters.category = gridNode.name;
tick.options = options;
tick.addLabel();
}
}
else {
proceed.apply(axis, Array.prototype.slice.call(arguments, 1));
}
}
/**
* @private
*/
function wrapInit(proceed, chart, userOptions, coll) {
const axis = this, isTreeGrid = userOptions.type === 'treegrid';
if (!axis.treeGrid) {
axis.treeGrid = new TreeGridAxisAdditions(axis);
}
// Set default and forced options for TreeGrid
if (isTreeGrid) {
// Add event for updating the categories of a treegrid.
// NOTE Preferably these events should be set on the axis.
addEvent(chart, 'beforeRender', onBeforeRender);
addEvent(chart, 'beforeRedraw', onBeforeRender);
// Add new collapsed nodes on addseries
addEvent(chart, 'addSeries', function (e) {
if (e.options.data) {
const treeGrid = getTreeGridFromData(e.options.data, userOptions.uniqueNames || false, 1);
axis.treeGrid.collapsedNodes = (axis.treeGrid.collapsedNodes || []).concat(treeGrid.collapsedNodes);
}
});
// Collapse all nodes in axis.treegrid.collapsednodes
// where collapsed equals true.
addEvent(axis, 'foundExtremes', function () {
if (axis.treeGrid.collapsedNodes) {
axis.treeGrid.collapsedNodes.forEach(function (node) {
const breaks = axis.treeGrid.collapse(node);
if (axis.brokenAxis) {
axis.brokenAxis.setBreaks(breaks, false);
// Remove the node from the axis collapsedNodes
if (axis.treeGrid.collapsedNodes) {
axis.treeGrid.collapsedNodes = axis.treeGrid
.collapsedNodes
.filter((n) => ((node.collapseStart !==
n.collapseStart) ||
node.collapseEnd !== n.collapseEnd));
}
}
});
}
});
// If staticScale is not defined on the yAxis
// and chart height is set, set axis.isDirty
// to ensure collapsing works (#12012)
addEvent(axis, 'afterBreaks', function () {
if (axis.coll === 'yAxis' &&
!axis.staticScale &&
axis.chart.options.chart.height) {
axis.isDirty = true;
}
});
userOptions = merge({
// Default options
grid: {
enabled: true
},
// TODO: add support for align in treegrid.
labels: {
align: 'left',
/**
* Set options on specific levels in a tree grid axis. Takes
* precedence over labels options.
*
* @sample {gantt} gantt/treegrid-axis/labels-levels
* Levels on TreeGrid Labels
*
* @type {Array<*>}
* @product gantt
* @apioption yAxis.labels.levels
*
* @private
*/
levels: [{
/**
* Specify the level which the options within this object
* applies to.
*
* @type {number}
* @product gantt
* @apioption yAxis.labels.levels.level
*
* @private
*/
level: void 0
}, {
level: 1,
/**
* @type {Highcharts.CSSObject}
* @product gantt
* @apioption yAxis.labels.levels.style
*
* @private
*/
style: {
/** @ignore-option */
fontWeight: 'bold'
}
}],
/**
* The symbol for the collapse and expand icon in a
* treegrid.
*
* @product gantt
* @optionparent yAxis.labels.symbol
*
* @private
*/
symbol: {
/**
* The symbol type. Points to a definition function in
* the `Highcharts.Renderer.symbols` collection.
*
* @type {Highcharts.SymbolKeyValue}
*
* @private
*/
type: 'triangle',
x: -5,
y: -5,
height: 10,
width: 10
}
},
uniqueNames: false
}, userOptions, {
// Forced options
reversed: true
});
}
// Now apply the original function with the original arguments, which are
// sliced off this function's arguments
proceed.apply(axis, [chart, userOptions, coll]);
if (isTreeGrid) {
axis.hasNames = true;
axis.options.showLastLabel = true;
}
}
/**
* Set the tick positions, tickInterval, axis min and max.
*
* @private
* @function Highcharts.GridAxis#setTickInterval
*
* @param {Function} proceed
* The original setTickInterval function.
*/
function wrapSetTickInterval(proceed) {
const axis = this, options = axis.options, time = axis.chart.time, linkedParent = typeof options.linkedTo === 'number' ?
this.chart[axis.coll]?.[options.linkedTo] :
void 0, isTreeGrid = axis.type === 'treegrid';
if (isTreeGrid) {
axis.min = axis.userMin ?? time.parse(options.min) ?? axis.dataMin;
axis.max = axis.userMax ?? time.parse(options.max) ?? axis.dataMax;
fireEvent(axis, 'foundExtremes');
// `setAxisTranslation` modifies the min and max according to axis
// breaks.
axis.setAxisTranslation();
axis.tickInterval = 1;
axis.tickmarkOffset = 0.5;
axis.tickPositions = axis.treeGrid.mapOfPosToGridNode ?
axis.treeGrid.getTickPositions() :
[];
if (linkedParent) {
const linkedParentExtremes = linkedParent.getExtremes();
axis.min = pick(linkedParentExtremes.min, linkedParentExtremes.dataMin);
axis.max = pick(linkedParentExtremes.max, linkedParentExtremes.dataMax);
axis.tickPositions = linkedParent.tickPositions;
}
axis.linkedParent = linkedParent;
}
else {
proceed.apply(axis, Array.prototype.slice.call(arguments, 1));
}
}
/**
* Wrap axis redraw to remove TreeGrid events from ticks
*
* @private
* @function Highcharts.GridAxis#redraw
*
* @param {Function} proceed
* The original setTickInterval function.
*/
function wrapRedraw(proceed) {
const axis = this, isTreeGrid = this.type === 'treegrid';
if (isTreeGrid && axis.visible) {
axis.tickPositions.forEach(function (pos) {
const tick = axis.ticks[pos];
if (tick.label?.attachedTreeGridEvents) {
removeEvent(tick.label.element);
tick.label.attachedTreeGridEvents = false;
}
});
}
proceed.apply(axis, Array.prototype.slice.call(arguments, 1));
}
/* *
*
* Classes
*
* */
/**
* @private
* @class
*/
class TreeGridAxisAdditions {
/* *
*
* Static Functions
*
* */
/**
* @private
*/
static compose(AxisClass, ChartClass, SeriesClass, TickClass) {
if (!AxisClass.keepProps.includes('treeGrid')) {
const axisProps = AxisClass.prototype;
AxisClass.keepProps.push('treeGrid');
wrap(axisProps, 'generateTick', wrapGenerateTick);
wrap(axisProps, 'init', wrapInit);
wrap(axisProps, 'setTickInterval', wrapSetTickInterval);
wrap(axisProps, 'redraw', wrapRedraw);
// Make utility functions available for testing.
axisProps.utils = {
getNode: Tree.getNode
};
if (!TickConstructor) {
TickConstructor = TickClass;
}
}
GridAxis.compose(AxisClass, ChartClass, TickClass);
BrokenAxis.compose(AxisClass, SeriesClass);
TreeGridTick.compose(TickClass);
return AxisClass;
}
/* *
*
* Constructors
*
* */
/**
* @private
*/
constructor(axis) {
this.axis = axis;
}
/* *
*
* Functions
*
* */
/**
* Set the collapse status.
*
* @private
*
* @param {Highcharts.Axis} axis
* The axis to check against.
*
* @param {Highcharts.GridNode} node
* The node to collapse.
*/
setCollapsedStatus(node) {
const axis = this.axis, chart = axis.chart;
axis.series.forEach(function (series) {
const data = series.options.data;
if (node.id && data) {
const point = chart.get(node.id), dataPoint = data[series.data.indexOf(point)];
if (point && dataPoint) {
point.collapsed = node.collapsed;
dataPoint.collapsed = node.collapsed;
}
}
});
}
/**
* Calculates the new axis breaks to collapse a node.
*
* @private
*
* @param {Highcharts.Axis} axis
* The axis to check against.
*
* @param {Highcharts.GridNode} node
* The node to collapse.
*
* @param {number} pos
* The tick position to collapse.
*
* @return {Array<object>}
* Returns an array of the new breaks for the axis.
*/
collapse(node) {
const axis = this.axis, breaks = (axis.options.breaks || []), obj = getBreakFromNode(node, axis.max);
breaks.push(obj);
// Change the collapsed flag #13838
node.collapsed = true;
axis.treeGrid.setCollapsedStatus(node);
return breaks;
}
/**
* Calculates the new axis breaks to expand a node.
*
* @private
*
* @param {Highcharts.Axis} axis
* The axis to check against.
*
* @param {Highcharts.GridNode} node
* The node to expand.
*
* @param {number} pos
* The tick position to expand.
*
* @return {Array<object>}
* Returns an array of the new breaks for the axis.
*/
expand(node) {
const axis = this.axis, breaks = (axis.options.breaks || []), obj = getBreakFromNode(node, axis.max);
// Change the collapsed flag #13838
node.collapsed = false;
axis.treeGrid.setCollapsedStatus(node);
// Remove the break from the axis breaks array.
return breaks.reduce(function (arr, b) {
if (b.to !== obj.to || b.from !== obj.from) {
arr.push(b);
}
return arr;
}, []);
}
/**
* Creates a list of positions for the ticks on the axis. Filters out
* positions that are outside min and max, or is inside an axis break.
*
* @private
*
* @return {Array<number>}
* List of positions.
*/
getTickPositions() {
const axis = this.axis, roundedMin = Math.floor(axis.min / axis.tickInterval) * axis.tickInterval, roundedMax = Math.ceil(axis.max / axis.tickInterval) * axis.tickInterval;
return Object.keys(axis.treeGrid.mapOfPosToGridNode || {}).reduce(function (arr, key) {
const pos = +key;
if (pos >= roundedMin &&
pos <= roundedMax &&
!axis.brokenAxis?.isInAnyBreak(pos)) {
arr.push(pos);
}
return arr;
}, []);
}
/**
* Check if a node is collapsed.
*
* @private
*
* @param {Highcharts.Axis} axis
* The axis to check against.
*
* @param {Object} node
* The node to check if is collapsed.
*
* @param {number} pos
* The tick position to collapse.
*
* @return {boolean}
* Returns true if collapsed, false if expanded.
*/
isCollapsed(node) {
const axis = this.axis, breaks = (axis.options.breaks || []), obj = getBreakFromNode(node, axis.max);
return breaks.some(function (b) {
return b.from === obj.from && b.to === obj.to;
});
}
/**
* Calculates the new axis breaks after toggling the collapse/expand
* state of a node. If it is collapsed it will be expanded, and if it is
* expanded it will be collapsed.
*
* @private
*
* @param {Highcharts.Axis} axis
* The axis to check against.
*
* @param {Highcharts.GridNode} node
* The node to toggle.
*
* @return {Array<object>}
* Returns an array of the new breaks for the axis.
*/
toggleCollapse(node) {
return (this.isCollapsed(node) ?
this.expand(node) :
this.collapse(node));
}
}
/* *
*
* Default Export
*
* */
export default TreeGridAxisAdditions;