UNPKG

highcharts

Version:
496 lines (495 loc) 20.3 kB
/* * * * (c) 2010-2025 Torstein Honsi * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import ApproximationRegistry from './ApproximationRegistry.js'; import DataGroupingDefaults from './DataGroupingDefaults.js'; import DataTableCore from '../../Data/DataTableCore.js'; import DateTimeAxis from '../../Core/Axis/DateTimeAxis.js'; import D from '../../Core/Defaults.js'; import SeriesRegistry from '../../Core/Series/SeriesRegistry.js'; const { series: { prototype: seriesProto } } = SeriesRegistry; import U from '../../Core/Utilities.js'; const { addEvent, defined, error, extend, isNumber, merge, pick, splat } = U; /* * * * Constants * * */ const baseGeneratePoints = seriesProto.generatePoints; /* * * * Functions * * */ /** * @private */ function adjustExtremes(xAxis, groupedXData) { // Make sure the X axis extends to show the first group (#2533) // But only for visible series (#5493, #6393) if (defined(groupedXData[0]) && isNumber(xAxis.min) && isNumber(xAxis.dataMin) && groupedXData[0] < xAxis.min) { if ((!defined(xAxis.options.min) && xAxis.min <= xAxis.dataMin) || xAxis.min === xAxis.dataMin) { xAxis.min = Math.min(groupedXData[0], xAxis.min); } xAxis.dataMin = Math.min(groupedXData[0], xAxis.dataMin); } // When the last anchor set, change the extremes that // the last point is visible (#12455). if (defined(groupedXData[groupedXData.length - 1]) && isNumber(xAxis.max) && isNumber(xAxis.dataMax) && groupedXData[groupedXData.length - 1] > xAxis.max) { if ((!defined(xAxis.options.max) && isNumber(xAxis.dataMax) && xAxis.max >= xAxis.dataMax) || xAxis.max === xAxis.dataMax) { xAxis.max = Math.max(groupedXData[groupedXData.length - 1], xAxis.max); } xAxis.dataMax = Math.max(groupedXData[groupedXData.length - 1], xAxis.dataMax); } } /** * @private */ function anchorPoints(series, groupedXData, xMax) { const options = series.options, dataGroupingOptions = options.dataGrouping, totalRange = (series.currentDataGrouping && series.currentDataGrouping.gapSize), xData = series.getColumn('x'); if (!(dataGroupingOptions && xData.length && totalRange && series.groupMap)) { return; } const groupedDataLastIndex = groupedXData.length - 1, anchor = dataGroupingOptions.anchor, firstAnchor = dataGroupingOptions.firstAnchor, lastAnchor = dataGroupingOptions.lastAnchor; let anchorIndexIterator = groupedXData.length - 1, anchorFirstIndex = 0; // Change the first point position, but only when it is // the first point in the data set not in the current zoom. if (firstAnchor && xData[0] >= groupedXData[0]) { anchorFirstIndex++; const groupStart = series.groupMap[0].start, groupLength = series.groupMap[0].length; let firstGroupEnd; if (isNumber(groupStart) && isNumber(groupLength)) { firstGroupEnd = groupStart + (groupLength - 1); } groupedXData[0] = { start: groupedXData[0], middle: groupedXData[0] + 0.5 * totalRange, end: groupedXData[0] + totalRange, firstPoint: xData[0], lastPoint: firstGroupEnd && xData[firstGroupEnd] }[firstAnchor]; } // Change the last point position but only when it is // the last point in the data set not in the current zoom, // or if it is not the 1st point simultaneously. if (groupedDataLastIndex > 0 && lastAnchor && totalRange && groupedXData[groupedDataLastIndex] >= xMax - totalRange) { anchorIndexIterator--; const lastGroupStart = series.groupMap[series.groupMap.length - 1].start; groupedXData[groupedDataLastIndex] = { start: groupedXData[groupedDataLastIndex], middle: groupedXData[groupedDataLastIndex] + 0.5 * totalRange, end: groupedXData[groupedDataLastIndex] + totalRange, firstPoint: lastGroupStart && xData[lastGroupStart], lastPoint: xData[xData.length - 1] }[lastAnchor]; } if (anchor && anchor !== 'start') { const shiftInterval = (totalRange * { middle: 0.5, end: 1 }[anchor]); // Anchor the rest of the points apart from the ones, that were // previously moved. while (anchorIndexIterator >= anchorFirstIndex) { groupedXData[anchorIndexIterator] += shiftInterval; anchorIndexIterator--; } } } /** * For the processed data, calculate the grouped data if needed. * * @private * @function Highcharts.Series#applyGrouping */ function applyGrouping(hasExtremesChanged) { const series = this, chart = series.chart, options = series.options, dataGroupingOptions = options.dataGrouping, groupingEnabled = series.allowDG !== false && dataGroupingOptions && pick(dataGroupingOptions.enabled, chart.options.isStock), reserveSpace = series.reserveSpace(), lastDataGrouping = this.currentDataGrouping; let currentDataGrouping, croppedData, revertRequireSorting = false; // Data needs to be sorted for dataGrouping if (groupingEnabled && !series.requireSorting) { series.requireSorting = revertRequireSorting = true; } // Skip if skipDataGrouping method returns false or if grouping is disabled // (in that order). const skip = skipDataGrouping(series, hasExtremesChanged) === false || !groupingEnabled; // Revert original requireSorting value if changed if (revertRequireSorting) { series.requireSorting = false; } if (skip) { return; } series.destroyGroupedData(); const table = dataGroupingOptions.groupAll ? series.dataTable : series.dataTable.modified || series.dataTable, processedXData = series.getColumn('x', !dataGroupingOptions.groupAll), xData = processedXData, plotSizeX = chart.plotSizeX, xAxis = series.xAxis, extremes = xAxis.getExtremes(), ordinal = xAxis.options.ordinal, groupPixelWidth = series.groupPixelWidth; let i, hasGroupedData; // Execute grouping if the amount of points is greater than the limit // defined in groupPixelWidth if (groupPixelWidth && xData && table.rowCount && plotSizeX && isNumber(extremes.min)) { hasGroupedData = true; // Force recreation of point instances in series.translate, #5699 series.isDirty = true; series.points = null; // #6709 const xMin = extremes.min, xMax = extremes.max, groupIntervalFactor = (ordinal && xAxis.ordinal && xAxis.ordinal.getGroupIntervalFactor(xMin, xMax, series)) || 1, interval = (groupPixelWidth * (xMax - xMin) / plotSizeX) * groupIntervalFactor, groupPositions = xAxis.getTimeTicks(DateTimeAxis.Additions.prototype.normalizeTimeTickInterval(interval, dataGroupingOptions.units || DataGroupingDefaults.units), // Processed data may extend beyond axis (#4907) Math.min(xMin, xData[0]), Math.max(xMax, xData[xData.length - 1]), xAxis.options.startOfWeek, processedXData, series.closestPointRange), groupedData = seriesProto.groupData.apply(series, [ table, groupPositions, dataGroupingOptions.approximation ]); let modified = groupedData.modified, groupedXData = modified.getColumn('x', true), gapSize = 0; // The smoothed option is deprecated, instead, there is a fallback // to the new anchoring mechanism. #12455. if (dataGroupingOptions?.smoothed && modified.rowCount) { dataGroupingOptions.firstAnchor = 'firstPoint'; dataGroupingOptions.anchor = 'middle'; dataGroupingOptions.lastAnchor = 'lastPoint'; error(32, false, chart, { 'dataGrouping.smoothed': 'use dataGrouping.anchor' }); } // Record what data grouping values were used for (i = 1; i < groupPositions.length; i++) { // The grouped gapSize needs to be the largest distance between // the group to capture varying group sizes like months or DST // crossing (#10000). Also check that the gap is not at the // start of a segment. if (!groupPositions.info.segmentStarts || groupPositions.info.segmentStarts.indexOf(i) === -1) { gapSize = Math.max(groupPositions[i] - groupPositions[i - 1], gapSize); } } currentDataGrouping = groupPositions.info; currentDataGrouping.gapSize = gapSize; series.closestPointRange = groupPositions.info.totalRange; series.groupMap = groupedData.groupMap; series.currentDataGrouping = currentDataGrouping; anchorPoints(series, groupedXData || [], xMax); if (reserveSpace && groupedXData) { adjustExtremes(xAxis, groupedXData); } // We calculated all group positions but we should render only the ones // within the visible range if (dataGroupingOptions.groupAll) { // Keep the reference to all grouped points for further calculation, // used in Heikin-Ashi and hollow candlestick series. series.allGroupedTable = modified; croppedData = series.cropData(modified, xAxis.min || 0, xAxis.max || 0); modified = croppedData.modified; groupedXData = modified.getColumn('x'); series.cropStart = croppedData.start; // #15005 } // Set the modified table series.dataTable.modified = modified; } else { series.groupMap = void 0; series.currentDataGrouping = void 0; } series.hasGroupedData = hasGroupedData; series.preventGraphAnimation = (lastDataGrouping && lastDataGrouping.totalRange) !== (currentDataGrouping && currentDataGrouping.totalRange); } /** * @private */ function compose(SeriesClass) { const seriesProto = SeriesClass.prototype; if (!seriesProto.applyGrouping) { const PointClass = SeriesClass.prototype.pointClass; // Override point prototype to throw a warning when trying to update // grouped points. addEvent(PointClass, 'update', function () { if (this.dataGroup) { error(24, false, this.series.chart); return false; } }); addEvent(SeriesClass, 'afterSetOptions', onAfterSetOptions); addEvent(SeriesClass, 'destroy', destroyGroupedData); extend(seriesProto, { applyGrouping, destroyGroupedData, generatePoints, getDGApproximation, groupData }); } } /** * Destroy the grouped data points. #622, #740 * @private */ function destroyGroupedData() { // Clear previous groups if (this.groupedData) { this.groupedData.forEach(function (point, i) { if (point) { this.groupedData[i] = point.destroy ? point.destroy() : null; } }, this); // Clears all: // - `this.groupedData` // - `this.points` // - `preserve` object in series.update() this.groupedData.length = 0; delete this.allGroupedTable; } } /** * Override the generatePoints method by adding a reference to grouped data * @private */ function generatePoints() { baseGeneratePoints.apply(this); // Record grouped data in order to let it be destroyed the next time // processData runs this.destroyGroupedData(); // #622 this.groupedData = this.hasGroupedData ? this.points : null; } /** * Set default approximations to the prototypes if present. Properties are * inherited down. Can be overridden for individual series types. * @private */ function getDGApproximation() { if (this.is('arearange')) { return 'range'; } if (this.is('ohlc')) { return 'ohlc'; } if (this.is('hlc')) { return 'hlc'; } if ( // #18974, default approximation for cumulative // should be `sum` when `dataGrouping` is enabled this.is('column') || this.options.cumulative) { return 'sum'; } return 'average'; } /** * Highcharts Stock only. Takes parallel arrays of x and y data and groups the * data into intervals defined by groupPositions, a collection of starting x * values for each group. * * @product highstock * * @function Highcharts.Series#groupData * @param {Highcharts.DataTable} table * The series data table. * @param {Array<number>} groupPositions * Group positions. * @param {string|Function} [approximation] * Approximation to use. * @return {Highcharts.DataGroupingResultObject} * Mapped groups. */ function groupData(table, groupPositions, approximation) { const xData = table.getColumn('x', true) || [], yData = table.getColumn('y', true), series = this, data = series.data, dataOptions = series.options && series.options.data, groupedXData = [], modified = new DataTableCore(), groupMap = [], dataLength = table.rowCount, // When grouping the fake extended axis for panning, we don't need to // consider y handleYData = !!yData, values = [], pointArrayMap = series.pointArrayMap, pointArrayMapLength = pointArrayMap && pointArrayMap.length, extendedPointArrayMap = ['x'].concat(pointArrayMap || ['y']), // Data columns to be applied to the modified data table at the end valueColumns = (pointArrayMap || ['y']).map(() => []), groupAll = (this.options.dataGrouping && this.options.dataGrouping.groupAll); let pointX, pointY, groupedY, pos = 0, start = 0; const approximationFn = (typeof approximation === 'function' ? approximation : approximation && ApproximationRegistry[approximation] ? ApproximationRegistry[approximation] : ApproximationRegistry[(series.getDGApproximation && series.getDGApproximation() || 'average')]); // Calculate values array size from pointArrayMap length if (pointArrayMapLength) { let len = pointArrayMap.length; while (len--) { values.push([]); } } else { values.push([]); } const valuesLen = pointArrayMapLength || 1; for (let i = 0; i <= dataLength; i++) { // Start with the first point within the X axis range (#2696) if (xData[i] < groupPositions[0]) { continue; // With next point } // When a new group is entered, summarize and initialize // the previous group while ((typeof groupPositions[pos + 1] !== 'undefined' && xData[i] >= groupPositions[pos + 1]) || i === dataLength) { // Get the last group // get group x and y pointX = groupPositions[pos]; series.dataGroupInfo = { start: groupAll ? start : (series.cropStart + start), length: values[0].length, groupStart: pointX }; groupedY = approximationFn.apply(series, values); // By default, let options of the first grouped point be passed over // to the grouped point. This allows preserving properties like // `name` and `color` or custom properties. Implementers can // override this from the approximation function, where they can // write custom options to `this.dataGroupInfo.options`. if (series.pointClass && !defined(series.dataGroupInfo.options)) { // Convert numbers and arrays into objects series.dataGroupInfo.options = merge(series.pointClass.prototype .optionsToObject.call({ series: series }, series.options.data[series.cropStart + start])); // Make sure the raw data (x, y, open, high etc) is not copied // over and overwriting approximated data. extendedPointArrayMap.forEach(function (key) { delete series.dataGroupInfo.options[key]; }); } // Push the grouped data if (typeof groupedY !== 'undefined') { groupedXData.push(pointX); // Push the grouped values to the parallel columns const groupedValuesArr = splat(groupedY); for (let j = 0; j < groupedValuesArr.length; j++) { valueColumns[j].push(groupedValuesArr[j]); } groupMap.push(series.dataGroupInfo); } // Reset the aggregate arrays start = i; for (let j = 0; j < valuesLen; j++) { values[j].length = 0; // Faster than values[j] = [] values[j].hasNulls = false; } // Advance on the group positions pos += 1; // Don't loop beyond the last group if (i === dataLength) { break; } } // Break out if (i === dataLength) { break; } // For each raw data point, push it to an array that contains all values // for this specific group if (pointArrayMap) { const index = groupAll ? i : series.cropStart + i, point = (data && data[index]) || series.pointClass.prototype.applyOptions.apply({ series: series }, [dataOptions[index]]); let val; for (let j = 0; j < pointArrayMapLength; j++) { val = point[pointArrayMap[j]]; if (isNumber(val)) { values[j].push(val); } else if (val === null) { values[j].hasNulls = true; } } } else { pointY = handleYData ? yData[i] : null; if (isNumber(pointY)) { values[0].push(pointY); } else if (pointY === null) { values[0].hasNulls = true; } } } const columns = { x: groupedXData }; (pointArrayMap || ['y']).forEach((key, i) => { columns[key] = valueColumns[i]; }); modified.setColumns(columns); return { groupMap, modified }; } /** * Handle default options for data grouping. This must be set at runtime because * some series types are defined after this. * @private */ function onAfterSetOptions(e) { const options = e.options, type = this.type, plotOptions = this.chart.options.plotOptions, // External series, for example technical indicators should also inherit // commonOptions which are not available outside this module baseOptions = (this.useCommonDataGrouping && DataGroupingDefaults.common), seriesSpecific = DataGroupingDefaults.seriesSpecific; let defaultOptions = D.defaultOptions.plotOptions[type].dataGrouping; if (plotOptions && (seriesSpecific[type] || baseOptions)) { // #1284 const rangeSelector = this.chart.rangeSelector; if (!defaultOptions) { defaultOptions = merge(DataGroupingDefaults.common, seriesSpecific[type]); } options.dataGrouping = merge(baseOptions, defaultOptions, plotOptions.series && plotOptions.series.dataGrouping, // #1228 // Set by the StockChart constructor: plotOptions[type].dataGrouping, this.userOptions.dataGrouping, !options.isInternal && rangeSelector && isNumber(rangeSelector.selected) && rangeSelector.buttonOptions[rangeSelector.selected].dataGrouping); } } /** * @private */ function skipDataGrouping(series, force) { return !(series.isCartesian && !series.isDirty && !series.xAxis.isDirty && !series.yAxis.isDirty && !force); } /* * * * Default Export * * */ const DataGroupingSeriesComposition = { compose, groupData }; export default DataGroupingSeriesComposition;