UNPKG

highcharts

Version:
535 lines (534 loc) 22.6 kB
/* * * * (c) 2010-2025 Torstein Honsi * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import SeriesRegistry from '../../Core/Series/SeriesRegistry.js'; const { column: ColumnSeries, line: LineSeries } = SeriesRegistry.seriesTypes; import U from '../../Core/Utilities.js'; const { addEvent, arrayMax, arrayMin, correctFloat, crisp, extend, isNumber, merge, objectEach, pick } = U; import WaterfallAxis from '../../Core/Axis/WaterfallAxis.js'; import WaterfallPoint from './WaterfallPoint.js'; import WaterfallSeriesDefaults from './WaterfallSeriesDefaults.js'; /* * * * Functions * * */ /** * Returns true if the key is a direct property of the object. * @private * @param {*} obj * Object with property to test * @param {string} key * Property key to test * @return {boolean} * Whether it is a direct property */ function ownProp(obj, key) { return Object.hasOwnProperty.call(obj, key); } /* * * * Class * * */ /** * Waterfall series type. * * @private */ class WaterfallSeries extends ColumnSeries { /* * * * Functions * * */ // After generating points, set y-values for all sums. generatePoints() { // Parent call: ColumnSeries.prototype.generatePoints.apply(this); const processedYData = this.getColumn('y', true); for (let i = 0, len = this.points.length; i < len; i++) { const point = this.points[i], y = processedYData[i]; // Override point value for sums. #3710 Update point does not // propagate to sum if (isNumber(y) && (point.isIntermediateSum || point.isSum)) { point.y = correctFloat(y); } } } // Call default processData then override yData to reflect waterfall's // extremes on yAxis processData(force) { const series = this, options = series.options, yData = series.getColumn('y'), // #3710 Update point does not propagate to sum points = options.data, dataLength = yData.length, threshold = options.threshold || 0; let point, subSum, sum, dataMin, dataMax, y; sum = subSum = dataMin = dataMax = 0; for (let i = 0; i < dataLength; i++) { y = yData[i]; point = points?.[i] || {}; if (y === 'sum' || point.isSum) { yData[i] = correctFloat(sum); } else if (y === 'intermediateSum' || point.isIntermediateSum) { yData[i] = correctFloat(subSum); subSum = 0; } else { sum += y; subSum += y; } dataMin = Math.min(sum, dataMin); dataMax = Math.max(sum, dataMax); } super.processData.call(this, force); // Record extremes only if stacking was not set: if (!options.stacking) { series.dataMin = dataMin + threshold; series.dataMax = dataMax; } return; } // Return y value or string if point is sum toYData(pt) { if (pt.isSum) { return 'sum'; } if (pt.isIntermediateSum) { return 'intermediateSum'; } return pt.y; } // Postprocess mapping between options and SVG attributes pointAttribs(point, state) { const upColor = this.options.upColor; // Set or reset up color (#3710, update to negative) if (upColor && !point.options.color && isNumber(point.y)) { point.color = point.y > 0 ? upColor : void 0; } const attr = ColumnSeries.prototype.pointAttribs.call(this, point, state); // The dashStyle option in waterfall applies to the graph, not // the points delete attr.dashstyle; return attr; } // Return an empty path initially, because we need to know the stroke-width // in order to set the final path. getGraphPath() { return [['M', 0, 0]]; } // Draw columns' connector lines getCrispPath() { const // Skip points where Y is not a number (#18636) data = this.data.filter((d) => isNumber(d.y)), yAxis = this.yAxis, length = data.length, graphLineWidth = this.graph?.strokeWidth() || 0, reversedXAxis = this.xAxis.reversed, reversedYAxis = this.yAxis.reversed, stacking = this.options.stacking, path = []; for (let i = 1; i < length; i++) { if (!( // Skip lines that would pass over the null point (#18636) this.options.connectNulls || isNumber(this.data[data[i].index - 1].y))) { continue; } const box = data[i].box, prevPoint = data[i - 1], prevY = prevPoint.y || 0, prevBox = data[i - 1].box; if (!box || !prevBox) { continue; } const prevStack = yAxis.waterfall?.stacks[this.stackKey], isPos = prevY > 0 ? -prevBox.height : 0; if (prevStack && prevBox && box) { const prevStackX = prevStack[i - 1]; // Y position of the connector is different when series are // stacked, yAxis is reversed and it also depends on point's // value let yPos; if (stacking) { const connectorThreshold = prevStackX.connectorThreshold; yPos = crisp(yAxis.translate(connectorThreshold, false, true, false, true) + (reversedYAxis ? isPos : 0), graphLineWidth); } else { yPos = crisp(prevBox.y + (prevPoint.minPointLengthOffset || 0), graphLineWidth); } path.push([ 'M', (prevBox.x || 0) + (reversedXAxis ? 0 : (prevBox.width || 0)), yPos ], [ 'L', (box.x || 0) + (reversedXAxis ? (box.width || 0) : 0), yPos ]); } if (prevBox && path.length && ((!stacking && prevY < 0 && !reversedYAxis) || (prevY > 0 && reversedYAxis))) { const nextLast = path[path.length - 2]; if (nextLast && typeof nextLast[2] === 'number') { nextLast[2] += prevBox.height || 0; } const last = path[path.length - 1]; if (last && typeof last[2] === 'number') { last[2] += prevBox.height || 0; } } } return path; } // The graph is initially drawn with an empty definition, then updated with // crisp rendering. drawGraph() { LineSeries.prototype.drawGraph.call(this); if (this.graph) { this.graph.attr({ d: this.getCrispPath() }); } } // Waterfall has stacking along the x-values too. setStackedPoints(axis) { const series = this, options = series.options, waterfallStacks = axis.waterfall?.stacks, seriesThreshold = options.threshold || 0, stackKey = series.stackKey, xData = series.getColumn('x'), yData = series.getColumn('y'), xLength = xData.length; let stackThreshold = seriesThreshold, interSum = stackThreshold, actualStackX, totalYVal = 0, actualSum = 0, prevSum = 0, statesLen, posTotal, negTotal, xPoint, yVal, x, alreadyChanged, changed; // Function responsible for calculating correct values for stackState // array of each stack item. The arguments are: firstS - the value for // the first state, nextS - the difference between the previous and the // newest state, sInx - counter used in the for that updates each state // when necessary, sOff - offset that must be added to each state when // they need to be updated (if point isn't a total sum) // eslint-disable-next-line require-jsdoc const calculateStackState = (firstS, nextS, sInx, sOff) => { if (actualStackX) { if (!statesLen) { actualStackX.stackState[0] = firstS; statesLen = actualStackX.stackState.length; } else { for (sInx; sInx < statesLen; sInx++) { actualStackX.stackState[sInx] += sOff; } } actualStackX.stackState.push(actualStackX.stackState[statesLen - 1] + nextS); } }; if (axis.stacking && waterfallStacks) { // Code responsible for creating stacks for waterfall series if (series.reserveSpace()) { changed = waterfallStacks.changed; alreadyChanged = waterfallStacks.alreadyChanged; // In case of a redraw, stack for each x value must be emptied // (only for the first series in a specific stack) and // recalculated once more if (alreadyChanged && alreadyChanged.indexOf(stackKey) < 0) { changed = true; } if (!waterfallStacks[stackKey]) { waterfallStacks[stackKey] = {}; } const actualStack = waterfallStacks[stackKey]; if (actualStack) { for (let i = 0; i < xLength; i++) { x = xData[i]; if (!actualStack[x] || changed) { actualStack[x] = { negTotal: 0, posTotal: 0, stackTotal: 0, threshold: 0, stateIndex: 0, stackState: [], label: ((changed && actualStack[x]) ? actualStack[x].label : void 0) }; } actualStackX = actualStack[x]; yVal = yData[i]; if (yVal >= 0) { actualStackX.posTotal += yVal; } else { actualStackX.negTotal += yVal; } // Points do not exist yet, so raw data is used xPoint = options.data[i]; posTotal = actualStackX.absolutePos = actualStackX.posTotal; negTotal = actualStackX.absoluteNeg = actualStackX.negTotal; actualStackX.stackTotal = posTotal + negTotal; statesLen = actualStackX.stackState.length; if (xPoint?.isIntermediateSum) { calculateStackState(prevSum, actualSum, 0, prevSum); prevSum = actualSum; actualSum = seriesThreshold; // Swapping values stackThreshold ^= interSum; interSum ^= stackThreshold; stackThreshold ^= interSum; } else if (xPoint?.isSum) { calculateStackState(seriesThreshold, totalYVal, statesLen, 0); stackThreshold = seriesThreshold; } else { calculateStackState(stackThreshold, yVal, 0, totalYVal); if (xPoint) { totalYVal += yVal; actualSum += yVal; } } actualStackX.stateIndex++; actualStackX.threshold = stackThreshold; stackThreshold += actualStackX.stackTotal; } } waterfallStacks.changed = false; if (!waterfallStacks.alreadyChanged) { waterfallStacks.alreadyChanged = []; } waterfallStacks.alreadyChanged.push(stackKey); } } } // Extremes for a non-stacked series are recorded in processData. // In case of stacking, use Series.stackedYData to calculate extremes. getExtremes() { const stacking = this.options.stacking, yAxis = this.yAxis, waterfallStacks = yAxis.waterfall?.stacks; let stackedYNeg, stackedYPos; if (stacking && waterfallStacks) { stackedYNeg = this.stackedYNeg = []; stackedYPos = this.stackedYPos = []; // The visible y range can be different when stacking is set to // overlap and different when it's set to normal if (stacking === 'overlap') { objectEach(waterfallStacks[this.stackKey], function (stackX) { stackedYNeg.push(arrayMin(stackX.stackState)); stackedYPos.push(arrayMax(stackX.stackState)); }); } else { objectEach(waterfallStacks[this.stackKey], function (stackX) { stackedYNeg.push(stackX.negTotal + stackX.threshold); stackedYPos.push(stackX.posTotal + stackX.threshold); }); } return { dataMin: arrayMin(stackedYNeg), dataMax: arrayMax(stackedYPos) }; } // When not stacking, data extremes have already been computed in the // processData function. return { dataMin: this.dataMin, dataMax: this.dataMax }; } } /* * * * Static Properties * * */ WaterfallSeries.defaultOptions = merge(ColumnSeries.defaultOptions, WaterfallSeriesDefaults); WaterfallSeries.compose = WaterfallAxis.compose; extend(WaterfallSeries.prototype, { pointValKey: 'y', // Property needed to prevent lines between the columns from disappearing // when negativeColor is used. showLine: true, pointClass: WaterfallPoint }); // Translate data points from raw values addEvent(WaterfallSeries, 'afterColumnTranslate', function () { const series = this, { options, points, yAxis } = series, minPointLength = pick(options.minPointLength, 5), halfMinPointLength = minPointLength / 2, threshold = options.threshold || 0, stacking = options.stacking, actualStack = yAxis.waterfall?.stacks[series.stackKey], processedYData = series.getColumn('y', true); let previousIntermediate = threshold, previousY = threshold, y, total, yPos, hPos; for (let i = 0; i < points.length; i++) { const point = points[i], yValue = processedYData[i], shapeArgs = point.shapeArgs, box = extend({ x: 0, y: 0, width: 0, height: 0 }, shapeArgs || {}); point.box = box; const range = [0, yValue], pointY = point.y || 0; // Code responsible for correct positions of stacked points // starts here if (stacking) { if (actualStack) { const actualStackX = actualStack[i]; if (stacking === 'overlap') { total = actualStackX.stackState[actualStackX.stateIndex--]; y = pointY >= 0 ? total : total - pointY; if (ownProp(actualStackX, 'absolutePos')) { delete actualStackX.absolutePos; } if (ownProp(actualStackX, 'absoluteNeg')) { delete actualStackX.absoluteNeg; } } else { if (pointY >= 0) { total = actualStackX.threshold + actualStackX.posTotal; actualStackX.posTotal -= pointY; y = total; } else { total = actualStackX.threshold + actualStackX.negTotal; actualStackX.negTotal -= pointY; y = total - pointY; } if (!actualStackX.posTotal) { if (isNumber(actualStackX.absolutePos) && ownProp(actualStackX, 'absolutePos')) { actualStackX.posTotal = actualStackX.absolutePos; delete actualStackX.absolutePos; } } if (!actualStackX.negTotal) { if (isNumber(actualStackX.absoluteNeg) && ownProp(actualStackX, 'absoluteNeg')) { actualStackX.negTotal = actualStackX.absoluteNeg; delete actualStackX.absoluteNeg; } } } if (!point.isSum) { // The connectorThreshold property is later used in // getCrispPath function to draw a connector line in a // correct place actualStackX.connectorThreshold = actualStackX.threshold + actualStackX.stackTotal; } if (yAxis.reversed) { yPos = (pointY >= 0) ? (y - pointY) : (y + pointY); hPos = y; } else { yPos = y; hPos = y - pointY; } point.below = yPos <= threshold; box.y = yAxis.translate(yPos, false, true, false, true); box.height = Math.abs(box.y - yAxis.translate(hPos, false, true, false, true)); const dummyStackItem = yAxis.waterfall?.dummyStackItem; if (dummyStackItem) { dummyStackItem.x = i; dummyStackItem.label = actualStack[i].label; dummyStackItem.setOffset(series.pointXOffset || 0, series.barW || 0, series.stackedYNeg[i], series.stackedYPos[i], void 0, this.xAxis); } } } else { // Up points y = Math.max(previousY, previousY + pointY) + range[0]; box.y = yAxis.translate(y, false, true, false, true); // Sum points if (point.isSum) { box.y = yAxis.translate(range[1], false, true, false, true); box.height = Math.min(yAxis.translate(range[0], false, true, false, true), yAxis.len) - box.y; // #4256 point.below = range[1] <= threshold; } else if (point.isIntermediateSum) { if (pointY >= 0) { yPos = range[1] + previousIntermediate; hPos = previousIntermediate; } else { yPos = previousIntermediate; hPos = range[1] + previousIntermediate; } if (yAxis.reversed) { // Swapping values yPos ^= hPos; hPos ^= yPos; yPos ^= hPos; } box.y = yAxis.translate(yPos, false, true, false, true); box.height = Math.abs(box.y - Math.min(yAxis.translate(hPos, false, true, false, true), yAxis.len)); previousIntermediate += range[1]; point.below = yPos <= threshold; // If it's not the sum point, update previous stack end position // and get shape height (#3886) } else { box.height = yValue > 0 ? yAxis.translate(previousY, false, true, false, true) - box.y : yAxis.translate(previousY, false, true, false, true) - yAxis.translate(previousY - yValue, false, true, false, true); previousY += yValue; point.below = previousY < threshold; } // #3952 Negative sum or intermediate sum not rendered correctly if (box.height < 0) { box.y += box.height; box.height *= -1; } } point.plotY = box.y; point.yBottom = box.y + box.height; if (box.height <= minPointLength && !point.isNull) { box.height = minPointLength; box.y -= halfMinPointLength; point.yBottom = box.y + box.height; point.plotY = box.y; if (pointY < 0) { point.minPointLengthOffset = -halfMinPointLength; } else { point.minPointLengthOffset = halfMinPointLength; } } else { // #8024, empty gaps in the line for null data if (point.isNull) { box.width = 0; } point.minPointLengthOffset = 0; } // Correct tooltip placement (#3014) const tooltipY = point.plotY + (point.negative ? box.height : 0); if (point.below) { // #15334 point.plotY += box.height; } if (point.tooltipPos) { if (series.chart.inverted) { point.tooltipPos[0] = yAxis.len - tooltipY; } else { point.tooltipPos[1] = tooltipY; } } // Check point position after recalculation (#16788) point.isInside = this.isPointInside(point); // Crisp vector coordinates const crispBottom = crisp(point.yBottom, series.borderWidth); box.y = crisp(box.y, series.borderWidth); box.height = crispBottom - box.y; merge(true, point.shapeArgs, box); } }, { order: 2 }); SeriesRegistry.registerSeriesType('waterfall', WaterfallSeries); /* * * * Export * * */ export default WaterfallSeries;