UNPKG

highcharts

Version:
754 lines (753 loc) 27.3 kB
/* * * * (c) 2016 Highsoft AS * Authors: Jon Arild Nygard * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; 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;