UNPKG

highcharts

Version:
1,218 lines 160 kB
/* * * * (c) 2010-2026 Highsoft AS * Author: Torstein Hønsi * * Integration of this software requires a license. * - For commercial use, see www.highcharts.com/license * - For non-commercial, see www.highcharts.com/license-eula * * * */ 'use strict'; import { animObject, setAnimation } from '../Animation/AnimationUtilities.js'; import DataTableCore from '../../Data/DataTableCore.js'; import D from '../Defaults.js'; const { defaultOptions } = D; import F from '../Foundation.js'; const { registerEventOptions } = F; import H from '../Globals.js'; const { svg, win } = H; import LegendSymbol from '../Legend/LegendSymbol.js'; import Point from './Point.js'; import SeriesDefaults from './SeriesDefaults.js'; import SeriesRegistry from './SeriesRegistry.js'; const { seriesTypes } = SeriesRegistry; import SVGElement from '../Renderer/SVG/SVGElement.js'; import T from '../Templating.js'; const { format } = T; import { addEvent, arrayMax, arrayMin, clamp, correctFloat, crisp, defined, destroyObjectProperties, diffObjects, erase, extend, fireEvent, getClosestDistance, internalClearTimeout, isArray, isNumber, isString, merge, objectEach, pick, pushUnique, removeEvent, syncTimeout } from '../../Shared/Utilities.js'; import { error, insertItem } from '../Utilities.js'; /* * * * Class * * */ /** * This is the base series prototype that all other series types inherit from. * A new series is initialized either through the * [series](https://api.highcharts.com/highcharts/series) * option structure, or after the chart is initialized, through * {@link Highcharts.Chart#addSeries}. * * The object can be accessed in a number of ways. All series and point event * handlers give a reference to the `series` object. The chart object has a * {@link Highcharts.Chart#series|series} property that is a collection of all * the chart's series. The point objects and axis objects also have the same * reference. * * Another way to reference the series programmatically is by `id`. Add an id * in the series configuration options, and get the series object by * {@link Highcharts.Chart#get}. * * Configuration options for the series are given in three levels. Options for * all series in a chart are given in the * [plotOptions.series](https://api.highcharts.com/highcharts/plotOptions.series) * object. Then options for all series of a specific type * are given in the plotOptions of that type, for example `plotOptions.line`. * Next, options for one single series are given in the series array, or as * arguments to `chart.addSeries`. * * The data in the series is stored in various arrays. * * - First, `series.options.data` contains all the original config options for * each point whether added by options or methods like `series.addPoint`. * * - The `series.dataTable` refers to an instance of [DataTableCore](https://api.highcharts.com/class-reference/Highcharts.Data) * or `DataTable` that contains the data in a tabular format. Individual * columns can be read from `series.getColumn()`. * * - Next, `series.data` contains those values converted to points, but in case * the series data length exceeds the `cropThreshold`, or if the data is * grouped, `series.data` doesn't contain all the points. It only contains the * points that have been created on demand. * * - Then there's `series.points` that contains all currently visible point * objects. In case of cropping, the cropped-away points are not part of this * array. The `series.points` array starts at `series.cropStart` compared to * `series.data` and `series.options.data`. If however the series data is * grouped, these can't be correlated one to one. * * @class * @name Highcharts.Series * * @param {Highcharts.Chart} chart * The chart instance. * * @param {Highcharts.SeriesOptionsType|object} options * The series options. */ class Series { constructor() { /* * * * Static Properties * * */ /** @internal */ this.zoneAxis = 'y'; // eslint-enable valid-jsdoc } /* * * * API JSDoc doclet copies for uninitialized properties * * */ /** * Read only. The chart that the series belongs to. * * @name Highcharts.Series#chart * @type {Highcharts.Chart} */ /** * Series color as used by the legend and some series types. * @name Highcharts.Series#color * @type {Highcharts.ColorType|undefined} */ /** * Read only. An array containing those values converted to points. * In case the series data length exceeds the `cropThreshold`, or if * the data is grouped, `series.data` doesn't contain all the * points. Also, in case a series is hidden, the `data` array may be * empty. In case of cropping, the `data` array may contain `undefined` * values, instead of points. To access raw values, * `series.options.data` will always be up to date. `Series.data` only * contains the points that have been created on demand. To modify the * data, use * {@link Highcharts.Series#setData} or * {@link Highcharts.Point#update}. * * @see Series.points * * @name Highcharts.Series#data * @type {Array<Highcharts.Point>} */ /** * Contains the maximum value of the series' data point. Some series * types like `networkgraph` do not support this property as they * lack a `y`-value. * @name Highcharts.Series#dataMax * @type {number|undefined} * @readonly */ /** * Contains the minimum value of the series' data point. Some series * types like `networkgraph` do not support this property as they * lack a `y`-value. * @name Highcharts.Series#dataMin * @type {number|undefined} * @readonly */ /** * The main group for the series' graphics. * * @name Highcharts.Series#group * @type {Highcharts.SVGElement} * @readonly */ /** * Contains the series' index in the `Chart.series` array. * * @name Highcharts.Series#index * @type {number} * @readonly */ /** * The parent series of the current series, if the current * series has a [linkedTo](https://api.highcharts.com/highcharts/series.line.linkedTo) * setting. * * @name Highcharts.Series#linkedParent * @type {Highcharts.Series} * @readonly */ /** * All child series that are linked to the current series through the * [linkedTo](https://api.highcharts.com/highcharts/series.line.linkedTo) * option. * * @name Highcharts.Series#linkedSeries * @type {Array<Highcharts.Series>} * @readonly */ /** * The series name as given in the options. Defaults to * "Series {n}". * * @name Highcharts.Series#name * @type {string} */ /** * Read only. The series' current options. To update, use * {@link Series#update}. * * @name Highcharts.Series#options * @type {Highcharts.SeriesOptionsType} */ /** * An array containing all currently visible point objects. In case * of cropping, the cropped-away points are not part of this array. * The `series.points` array starts at `series.cropStart` compared * to `series.data` and `series.options.data`. If however the series * data is grouped, these can't be correlated one to one. To modify * the data, use {@link Highcharts.Series#setData} or * {@link Highcharts.Point#update}. * * @name Highcharts.Series#points * @type {Array<Highcharts.Point>} */ /** * Read only. The series' selected state as set by {@link * Highcharts.Series#select}. * * @name Highcharts.Series#selected * @type {boolean} */ /** * Read only. The series' type, like "line", "area", "column" etc. * The type in the series options anc can be altered using * {@link Series#update}. * * @name Highcharts.Series#type * @type {string} */ /** * Read only. The series' visibility state as set by * {@link Series#show}, {@link Series#hide}, or in the initial * configuration. True by default. * * @name Highcharts.Series#visible * @type {boolean} * @default true */ /** * Read only. The unique xAxis object associated * with the series. * * @name Highcharts.Series#xAxis * @type {Highcharts.Axis} */ /** * Read only. The unique yAxis object associated * with the series. * * @name Highcharts.Series#yAxis * @type {Highcharts.Axis} */ /** * Contains series options by the user without defaults. * @name Highcharts.Series#userOptions * @type {Highcharts.SeriesOptionsType} */ /* * * * Functions * * */ /** @internal */ init(chart, userOptions) { fireEvent(this, 'init', { options: userOptions }); const series = this, chartSeries = chart.series; // The 'eventsToUnbind' property moved from prototype into the // Series init to avoid reference to the same array between // the different series and charts. #12959, #13937 this.eventsToUnbind = []; this.condemnedPoints || (this.condemnedPoints = []); /** * Read only. The chart that the series belongs to. * * @name Highcharts.Series#chart * @type {Highcharts.Chart} */ series.chart = chart; /** * Read only. The series' type, like "line", "area", "column" etc. * The type in the series options anc can be altered using * {@link Series#update}. * * @name Highcharts.Series#type * @type {string} */ /** * Read only. The series' current options. To update, use * {@link Series#update}. * * @name Highcharts.Series#options * @type {Highcharts.SeriesOptionsType} */ series.options = series.setOptions(userOptions); const options = series.options, visible = options.visible !== false; // Create the data table or use the one passed as option this.dataTable ?? (this.dataTable = isArray(options.dataTable) ? new DataTableCore() : options.dataTable?.isDataTable ? options.dataTable : new DataTableCore(options.dataTable)); /** * All child series that are linked to the current series through the * [linkedTo](https://api.highcharts.com/highcharts/series.line.linkedTo) * option. * * @name Highcharts.Series#linkedSeries * @type {Array<Highcharts.Series>} * @readonly */ series.linkedSeries = []; // Bind the axes series.bindAxes(); extend(series, { /** * The series name as given in the options. Defaults to * "Series {n}". * * @name Highcharts.Series#name * @type {string} */ name: options.name, state: '', /** * Read only. The series' visibility state as set by {@link * Series#show}, {@link Series#hide}, or in the initial * configuration. * * @name Highcharts.Series#visible * @type {boolean} */ visible, // True by default /** * Read only. The series' selected state as set by {@link * Highcharts.Series#select}. * * @name Highcharts.Series#selected * @type {boolean} */ selected: options.selected === true // False by default }); registerEventOptions(this, options); const events = options.events; if (events?.click || options.point?.events?.click || options.allowPointSelect) { chart.runTrackerClick = true; } series.getColor(); series.getSymbol(); // Mark cartesian if (series.isCartesian) { chart.hasCartesianSeries = true; } // Get the index and register the series in the chart. The index is // one more than the current latest series index (#5960). let lastSeries; if (chartSeries.length) { lastSeries = chartSeries[chartSeries.length - 1]; } series._i = pick(lastSeries?._i, -1) + 1; series.opacity = series.options.opacity; // Insert the series and re-order all series above the insertion // point. chart.orderItems('series', insertItem(this, chartSeries)); if (!series.points && !series.data) { series.setData(options.data, false); } fireEvent(this, 'afterInit'); } /** * Check whether the series item is itself or inherits from a certain * series type. * * @function Highcharts.Series#is * @param {string} type The type of series to check for, can be either * featured or custom series types. For example `column`, `pie`, * `ohlc` etc. * * @return {boolean} * True if this item is or inherits from the given type. */ // TODO: Runtime checks `instanceof`, so this also confirms inheritance. // The type guard currently narrows to the requested type only. Aligning // typing 1:1 with runtime should be easier after pending TS cleanups. is(type) { return seriesTypes[type] && this instanceof seriesTypes[type]; } /** * Set the xAxis and yAxis properties of cartesian series, and register * the series in the `axis.series` array. * * @internal * @function Highcharts.Series#bindAxes */ bindAxes() { const series = this, seriesOptions = series.options, chart = series.chart; let axisOptions; fireEvent(this, 'bindAxes', null, function () { // Repeat for xAxis and yAxis (series.axisTypes || []).forEach(function (coll) { // Loop through the chart's axis objects (chart[coll] || []).forEach(function (axis) { axisOptions = axis.options; // Apply if the series xAxis or yAxis option matches // the number of the axis, or if undefined, use the // first axis if (pick(seriesOptions[coll], 0) === axis.index || (typeof seriesOptions[coll] !== 'undefined' && seriesOptions[coll] === axisOptions.id)) { // Register this series in the axis.series lookup insertItem(series, axis.series); // Set this series.xAxis or series.yAxis reference series[coll] = axis; // Mark dirty for redraw axis.isDirty = true; } }); // The series needs an X and an Y axis if (!series[coll] && series.optionalAxis !== coll) { error(18, true, chart); } }); }); fireEvent(this, 'afterBindAxes'); } /** * Define hasData functions for series. These return true if there * are data points on this series within the plot area. * * @internal * @function Highcharts.Series#hasData */ hasData() { return ((this.visible && typeof this.dataMax !== 'undefined' && typeof this.dataMin !== 'undefined') || ( // #3703 this.visible && this.dataTable.rowCount > 0 // #9758 )); } /** * Determine whether the marker in a series has changed. * * @internal * @function Highcharts.Series#hasMarkerChanged */ hasMarkerChanged(options, oldOptions) { const marker = options.marker, oldMarker = oldOptions.marker || {}; return marker && ((oldMarker.enabled && !marker.enabled) || oldMarker.symbol !== marker.symbol || // #10870, #15946 oldMarker.height !== marker.height || // #16274 oldMarker.width !== marker.width // #16274 ); } /** * Return an auto incremented x value based on the pointStart and * pointInterval options. This is only used if an x value is not given * for the point that calls autoIncrement. * * @internal * @function Highcharts.Series#autoIncrement */ autoIncrement(x) { const options = this.options, { pointIntervalUnit, relativeXValue } = this.options, time = this.chart.time, xIncrement = this.xIncrement ?? time.parse(options.pointStart) ?? 0; let pointInterval; this.pointInterval = pointInterval = pick(this.pointInterval, options.pointInterval, 1); if (relativeXValue && isNumber(x)) { pointInterval *= x; } // Added code for pointInterval strings if (pointIntervalUnit) { const d = time.toParts(xIncrement); if (pointIntervalUnit === 'day') { d[2] += pointInterval; } else if (pointIntervalUnit === 'month') { d[1] += pointInterval; } else if (pointIntervalUnit === 'year') { d[0] += pointInterval; } pointInterval = time.makeTime.apply(time, d) - xIncrement; } if (relativeXValue && isNumber(x)) { return xIncrement + pointInterval; } this.xIncrement = xIncrement + pointInterval; return xIncrement; } /** * Set the series options by merging from the options tree. Called * internally on initializing and updating series. This function will * not redraw the series. For API usage, use {@link Series#update}. * * @internal * @function Highcharts.Series#setOptions * @param {Highcharts.SeriesOptionsType} itemOptions * The series options. * @emits Highcharts.Series#event:afterSetOptions */ setOptions(itemOptions) { const chart = this.chart, chartOptions = chart.options, plotOptions = chartOptions.plotOptions, userOptions = chart.userOptions || {}, seriesUserOptions = merge(itemOptions), styledMode = chart.styledMode, e = { plotOptions: plotOptions, userOptions: seriesUserOptions }; let zone; fireEvent(this, 'setOptions', e); // These may be modified by the event const typeOptions = e.plotOptions[this.type], userPlotOptions = (userOptions.plotOptions || {}), userPlotOptionsSeries = userPlotOptions.series || {}, defaultPlotOptionsType = (defaultOptions.plotOptions[this.type] || {}), userPlotOptionsType = userPlotOptions[this.type] || {}; // Merge in multiple data label options from the plot option. (#21928) typeOptions.dataLabels = this.mergeArrays(defaultPlotOptionsType.dataLabels, typeOptions.dataLabels); // Use copy to prevent undetected changes (#9762) this.userOptions = e.userOptions; const options = merge(typeOptions, plotOptions.series, // #3881, chart instance plotOptions[type] should trump // plotOptions.series userPlotOptionsType, seriesUserOptions), // Handle color zones { negativeColor, negativeFillColor, zoneAxis = 'y', zones } = options, // #20440, create deep copy of zones options zonesCopy = (zones || []).map((z) => ({ ...z })); // The tooltip options are merged between global and series specific // options. Importance in ascending order: // globals: (1)tooltip, (2)plotOptions.series, // (3)plotOptions[this.type] // init userOptions with possible later updates: 4-6 like 1-3 and // (7)this series options this.tooltipOptions = merge(defaultOptions.tooltip, // 1 defaultOptions.plotOptions.series?.tooltip, // 2 defaultPlotOptionsType?.tooltip, // 3 chart.userOptions.tooltip, // 4 userPlotOptions.series?.tooltip, // 5 userPlotOptionsType.tooltip, // 6 seriesUserOptions.tooltip // 7 ); // When shared tooltip, stickyTracking is true by default, // unless user says otherwise. this.stickyTracking = pick(seriesUserOptions.stickyTracking, userPlotOptionsType.stickyTracking, userPlotOptionsSeries.stickyTracking, (this.tooltipOptions.shared && !this.noSharedTooltip ? true : options.stickyTracking)); // Delete marker object if not allowed (#1125) if (typeOptions.marker === null) { delete options.marker; } this.zones || (this.zones = zonesCopy); this.zoneAxis = zoneAxis; if ((negativeColor || negativeFillColor) && !zones) { zone = { value: options[zoneAxis + 'Threshold'] || options.threshold || 0, className: 'highcharts-negative' }; if (!styledMode) { // Styled mode allows boolean if (typeof negativeColor !== 'boolean') { zone.color = negativeColor; } zone.fillColor = negativeFillColor; } zonesCopy.push(zone); } // Push one extra zone for the rest if (zonesCopy.length && defined(zonesCopy[zonesCopy.length - 1].value)) { zonesCopy.push(styledMode ? {} : { color: this.color, fillColor: this.fillColor }); } fireEvent(this, 'afterSetOptions', { options }); return options; } /** * Return the name for the series. Looks for a `name` in the options. If not * found, looks for a column name in the data mapping. If not found, returns * a default name based on the series type and index in `Series {n}`" * format. This method can be simply overridden as series name format can * vary (e.g. technical indicators). * * @function Highcharts.Series#getName * * @return {string} The series name. */ getName() { const { chart, options } = this, { dataMapping, name } = options, valueMapping = dataMapping?.y || dataMapping?.value, columnKey = isString(valueMapping) ? valueMapping : valueMapping?.column; // #4119 return name ?? (isString(columnKey) ? columnKey : format(chart.options.lang.seriesName, this, chart)); } /** * Set series-specific properties for color and symbol. Called internally * from Series.update(). * * @internal * @function Highcharts.Series#getCyclic * * @param {'color'|'symbol'} prop * The property to set, either `color` or `symbol`. * @param {*} [value] * The value to set. If not given, the next available value is used. * @param {Highcharts.Dictionary<*>} [defaults] * The default values. */ getCyclic(prop, value, defaults) { const chart = this.chart, indexName = `${prop}Index`, counterName = `${prop}Counter`, len = ( // Symbol count defaults?.length || // Color count chart.options.chart.colorCount); let i, setting; if (!value) { // Pick up either the colorIndex option, or the series.colorIndex // after Series.update() setting = pick(prop === 'color' ? this.options.colorIndex : void 0, this[indexName]); if (defined(setting)) { // After Series.update() i = setting; } else { // #6138 if (!chart.series.length) { chart[counterName] = 0; } i = chart[counterName] % len; chart[counterName] += 1; } if (defaults) { value = defaults[i]; } } // Set the colorIndex if (typeof i !== 'undefined') { this[indexName] = i; } this[prop] = value; } /** * Get the series' color based on either the options or pulled from * global options. * * @internal * @function Highcharts.Series#getColor */ getColor() { const chart = this.chart; if (chart.styledMode) { this.getCyclic('color'); } else if (this.options.colorByPoint) { this.color = 'var(--highcharts-neutral-color-20)'; } else { this.getCyclic('color', this.options.color || defaultOptions.plotOptions[this.type]?.color, chart.options.colors); } } /** * Get all points' instances created for this series. * * @internal * @function Highcharts.Series#getPointsCollection */ getPointsCollection() { return (this.hasGroupedData ? this.points : this.data) || []; } /** * Get the series' symbol based on either the options or pulled from * global options. * * @internal * @function Highcharts.Series#getSymbol */ getSymbol() { const seriesMarkerOption = this.options.marker; this.getCyclic('symbol', seriesMarkerOption?.symbol, this.chart.options.symbols); } /** * Shorthand to get one of the series' data columns from `Series.dataTable`. * * @internal * @function Highcharts.Series#getColumn */ getColumn(columnId, modified, matchLength) { const table = modified ? this.dataTable.getModified() : this.dataTable, rowCount = table.rowCount, usingModified = this.dataTable !== table, column = table.getColumn(columnId, true); // When there is no x column in the data set, generate an internal x // column for the series. The `xColumn` array is cached and reused, but // cleared on series update. if (columnId === 'x' && !usingModified) { // Return cached xColumn if it exists if (this.xColumn) { return this.xColumn; } const nameColumn = table.getColumn('name', true), options = this.options, // Check for empty or non-numeric x values. A for loop is faster // than Array.prototype.some, and covers empty slots. Cache the // result for faster subsequent checks. isNumbers = (arr) => { if (this.xColumnIsNumbers !== void 0) { return this.xColumnIsNumbers; } for (const x of arr) { if (typeof x !== 'number') { return (this.xColumnIsNumbers = false); } } return (this.xColumnIsNumbers = true); }; // Reset the counter this.xIncrement = null; // Under these conditions, we need to generate the x data if (!column || this.xAxis?.hasNames || options.relativeXValue || // X column exists in the data table, but has gaps or strings (column.length < (options.turboThreshold || Infinity) && !this.boosted && !isNumbers(column))) { const xColumn = []; for (let i = 0; i < rowCount; i++) { const xOption = column?.[i]; if (!defined(xOption)) { // When x values are missing, make sure we // auto-increment from the last point, not from zero. // Otherwise date-axes would be extended from // 1970-01-01. this.xIncrement ?? (this.xIncrement = xColumn[xColumn.length - 1] ?? null); } xColumn.push(this.getX(xOption, nameColumn?.[i])); } return (this.xColumn = xColumn); } } return column || Array(matchLength ? rowCount : 0); } /** * Get the x value for a given point. * * @internal */ getX(xOption, name) { if (this.xAxis?.hasNames && this.dataTable.getColumn('name', true) && defined(name)) { return this.xAxis.nameToX({ name, series: this }, xOption); } if (typeof xOption === 'undefined' || (isNumber(xOption) && this.options.relativeXValue)) { return this.autoIncrement(xOption); } // If x is a string, try to parse it to a datetime if (typeof xOption === 'string') { xOption = this.chart.time.parse(xOption); if (isNumber(xOption)) { return xOption; } } return xOption; } /** * Internal function called from setData. If the point count is the same * as it was, or if there are overlapping X values, just run * Point.update which is cheaper, allows animation, and keeps references * to points. This also allows adding or removing points if the X-es * don't match. * * @internal * @function Highcharts.Series#matchPoints */ matchPoints(oldXColumn, oldIdColumn, oldNameColumn, // Index matching is used by the data-sorting module oldIndexColumn) { const { dataTable, options, requireSorting } = this, dataSorting = options.dataSorting, oldData = this.data, rowsToAdd = [], rowsToUpdate = [], equalLength = dataTable.rowCount === oldData.length; let hasUpdatedByKey, i, point, lastIndex = 0, succeeded = true; this.xIncrement = null; delete this.xColumn; const newXColumn = dataTable.getColumn('x'), newIdColumn = dataTable.getColumn('id'), newNameColumn = dataSorting?.matchByName ? dataTable.getColumn('name') : void 0, newIndexColumn = dataTable.getColumn('index'); // Iterate the new data for (i = 0; i < dataTable.rowCount; i++) { const x = newXColumn?.[i], id = newIdColumn?.[i], name = newNameColumn?.[i], index = newIndexColumn?.[i], [needle, haystack] = id && oldIdColumn ? [id, oldIdColumn] : name && oldNameColumn ? [name, oldNameColumn] : defined(index) && oldIndexColumn ? [index, oldIndexColumn] : defined(x) && oldXColumn ? [x, oldXColumn] : []; let pointIndex = -1; // We have a needle and a haystack to search for matching points if (haystack) { pointIndex = haystack.indexOf(needle, lastIndex); // Matching X not found or used already due to non-unique x // values (#8995), add point (but later) if (pointIndex === -1) { const optionsX = newXColumn?.[i]; let newIndex = oldXColumn?.length ?? dataTable.rowCount; while (newIndex && oldXColumn && typeof optionsX === 'number' && oldXColumn[newIndex - 1] > optionsX) { newIndex--; } rowsToAdd.push({ newIndex, oldIndex: i }); // Matching X found, update } else if (oldData[pointIndex] /* && pOptions === oldData[pointIndex]?.options*/) { rowsToUpdate.push({ newIndex: pointIndex, oldIndex: i }); // Mark it touched, below we will remove all points that // are not touched. oldData[pointIndex].touched = true; // Speed optimize by only searching after last known // index. Performs ~20% better on large data sets. if (requireSorting) { lastIndex = pointIndex + 1; } // Point exists, no changes, don't remove it } /*/ else if (oldData[pointIndex]) { oldData[pointIndex].touched = true; }*/ // If the length is equal and some of the nodes had a // match in the same position, we don't want to remove // non-matches. if (!equalLength || i !== pointIndex || dataSorting?.enabled || this.hasDerivedData) { hasUpdatedByKey = true; } } else { // Gather all points that are not matched rowsToAdd.push({ newIndex: i, oldIndex: i }); } } // Remove points that don't exist in the updated data set if (hasUpdatedByKey) { // Update matching points rowsToUpdate.forEach((row) => { oldData[row.newIndex].applyOptions(dataTable.getRowObject(row.oldIndex)); }); // Add new points rowsToAdd.sort((a, b) => b.newIndex - a.newIndex); rowsToAdd.forEach((data) => { // Splice in an undefined item, `generatePoints` will pick it // up and create the point oldData.splice(data.newIndex, 0, void 0); }); // Remove points not touched i = oldData.length; while (i--) { point = oldData[i]; if (point && !point.touched) { point.destroy(); oldData.splice(i, 1); } } this.isDirtyData = this.isDirty = true; // If we did not find keys (ids or x-values), and the length is the // same, update one-to-one } else if (equalLength && !dataSorting?.enabled) { for (i = 0; i < dataTable.rowCount; i++) { if (!oldData[i].destroyed && !oldData[i].condemned) { const pOptions = dataTable.getRowObject(i); if (pOptions) { // Remove undefined properties, but preserve explicit // nulls (#24872) Object.keys(pOptions).forEach((key) => { if (pOptions[key] === void 0) { delete pOptions[key]; } }); if (Object.keys(pOptions).length) { oldData[i].update(pOptions, false, void 0, false); } } } } // Did not succeed in updating data } else { succeeded = false; } oldData.forEach((point) => { if (point) { point.touched = false; } }); if (!succeeded) { return false; } const xData = this.getColumn('x'); if (this.xIncrement === null && xData.length) { this.xIncrement = arrayMax(xData); this.autoIncrement(); } return true; } getDataColumnKeys() { return this.dataColumnKeys || ['x', ...(this.pointArrayMap || ['y'])]; } /** * Apply a new set of data to the series and optionally redraw it. The new * data array is passed by reference (except in case of `updatePoints`), and * may later be mutated when updating the chart data. * * Note the difference in behavior when setting the same amount of points, * or a different amount of points, as handled by the `updatePoints` * parameter. * * @sample highcharts/members/series-setdata/ * Set new data from a button * @sample highcharts/members/series-setdata-pie/ * Set data in a pie * @sample stock/members/series-setdata/ * Set new data in Highcharts Stock * @sample maps/members/series-setdata/ * Set new data in Highmaps * * @function Highcharts.Series#setData * * @param {Array<Highcharts.PointOptionsType>|Highcharts.DataTableOptionsObject|Highcharts.DataTable|undefined} data * Takes an array of data in the same format as described under * `series.{type}.data` for the given series type, for example a line * series would take data in the form described under * [series.line.data](https://api.highcharts.com/highcharts/series.line.data). * * @param {boolean} [redraw=true] * Whether to redraw the chart after the series is altered. If doing * more operations on the chart, it is a good idea to set redraw to * false and call {@link Chart#redraw} after. * * @param {boolean|Partial<Highcharts.AnimationOptionsObject>} [animation] * When the updated data is the same length as the existing data, * points will be updated by default, and animation visualizes how * the points are changed. Set false to disable animation, or a * configuration object to set duration or easing. * * @param {boolean} [updatePoints=true] * When this is true, points will be updated instead of replaced * whenever possible. This occurs a) when the updated data is the * same length as the existing data, b) when points are matched by * their id's, or c) when points can be matched by X values. This * allows updating with animation and performs better. In this case, * the original array is not passed by reference. Set `false` to * prevent. */ setData(data, redraw = true, animation, updatePoints) { const series = this, table = this.dataTable, options = series.options, oldData = series.points, oldDataLength = oldData?.length || 0, oldXColumn = table.getColumn('x'), oldIdColumn = table.getColumn('id'), oldNameColumn = ( // To get the bar race right. When data sorting is enabled, the // point order is not in sync with the table order. Could this // be done in a better way, maybe in the data sorting module? options.dataSorting?.matchByName && oldData?.map((point) => point.name)) || table.getColumn('name'), oldIndexColumn = table.getColumn('index'), chart = series.chart, xAxis = series.xAxis; let updatedData, i, copiedData; if (!chart.options.chart.allowMutatingData) { // #4259 // Remove old reference if (options.data) { delete series.options.data; } if (series.userOptions.data) { delete series.userOptions.data; } copiedData = merge(true, data); } data = copiedData || data; // Reset properties series.xIncrement = null; delete series.xColumn; delete series.xColumnIsNumbers; if (table !== options.dataTable) { delete table.columns.x; } series.colorCounter = 0; // For series with colorByPoint (#1547) // Array passed as option if (isArray(data)) { this.setDataFromArray(data); // Data table passed as option, either on series or chart } else { this.setDataFromTable(data); } if (updatePoints !== false && oldDataLength && !series.cropped && !series.hasGroupedData && series.visible && // Soft updating has no benefit in boost, and causes JS error // (#8355) !series.boosted) { updatedData = this.matchPoints(oldXColumn, oldIdColumn, oldNameColumn, oldIndexColumn); } if (!updatedData) { // Forgetting to cast strings to numbers is a common caveat when // handling CSV or JSON if (isString(this.getColumn('y')[0])) { error(14, true, chart); } series.data = []; // Destroy old points i = oldDataLength; while (i--) { oldData[i]?.destroy(); } // Reset minRange (#878) if (xAxis) { xAxis.minRange = xAxis.userMinRange; } // Redraw series.isDirty = chart.isDirtyBox = true; series.isDirtyData = !!oldData; animation = false; } if (isArray(data)) { series.options.data = series.userOptions.data = data; } // Typically for pie series, points need to be processed and generated // prior to rendering the legend if (options.legendType === 'point') { this.processData(); this.generatePoints(); } if (redraw) { chart.redraw(animation); } } /** * Internal function to set data from an array of point options - objects, * arrays or numbers. This corresponds to the `data` series option. Called * from the official `setData` method. * * @param {Array<Highcharts.PointOptionsType>} data * The data array * @internal */ setDataFromArray(data) { const dataLength = data.length, { keys, turboThreshold } = this.options, { pointValKey = 'y', pointArrayMap = [] } = this, valueCount = pointArrayMap.length, table = this.dataTable, dataColumnKeys = this.getDataColumnKeys(); // In turbo mode, look for one- or twodimensional arrays of numbers. The // first and the last valid value are tested, and we assume that all the // rest are defined the same way. Although the 'for' loops are similar, // they are repeated inside each if-else conditional for max // performance. let runTurbo = turboThreshold && dataLength > turboThreshold, indexOfX = 0, indexOfY = 1; if (runTurbo) { const firstPoint = this.getFirstValidPoint(data), lastPoint = this.getFirstValidPoint(data, dataLength - 1, -1), isShortArray = (a) => Boolean(isArray(a) && (keys || isNumber(a[0]))); // Assume all points are numbers if (isNumber(firstPoint) && isNumber(lastPoint)) { table.setColumn(pointValKey, data); // Assume all points are arrays when first point is } else if (isShortArray(firstPoint) && isShortArray(lastPoint)) { if (valueCount) { // [x, low, high] or [x, o, h, l, c] // When autoX is 1, the x is skipped: [low, high]. When // autoX is 0, the x is included: [x, low, high] const autoX = firstPoint.length === valueCount, colArray = new Array(firstPoint.length) .fill(0).map(() => []); for (const pt of data) { for (let j = 0; j <= valueCount; j++) { colArray[j]?.push(pt[j]); } } table.setColumns((autoX ? pointArrayMap : dataColumnKeys).reduce((columns, columnId, i) => { columns[columnId] = colArray[i]; return columns; }, {})); } else { // [x, y] if (keys) { indexOfX = keys.indexOf('x'); indexOfY = keys.indexOf('y'); indexOfX = indexOfX >= 0 ? indexOfX : 0; indexOfY = indexOfY >= 0 ? indexOfY : 1; } if (firstPoint.length === 1) { indexOfY = 0; } const xData = [], valueData = []; if (indexOfX === indexOfY) { for (const pt of data) { valueData.push(pt[indexOfY]); } table.setColumn(pointValKey, valueData); } else { for (const pt of data) { xData.push(pt[indexOfX]); valueData.push(pt[indexOfY]); } table.setColumns({ x: xData, [pointValKey]: valueData }); } } } else { // Highcharts expects configs to be numbers or arrays in turbo // mode runTurbo = false; } } if (!runTurbo) { const columns = {}; for (let i = 0; i < dataLength; i++) { const ptOptions = this.pointClass.prototype .optionsToObject .call({ series: this }, data[i]); for (const key of Object.keys(ptOptions)) { columns[key] || (columns[key] = new Array(dataLength)); columns[key][i] = ptOptions[key]; } } // Empty data, clear table if (dataLength) { table.setColumns(columns); } else { table.deleteRows(0, table.rowCount); } } } /** * Internal function to set data from a data table, either an instance or * options object. This corresponds to the `data` series option. Called * from the official `setData` method. * * @param {Highcharts.DataTableOptionsObject|Highcharts.DataTable} data * The data array * @internal */ setDataFromTable(data) { const { chart, options, dataTable: table } = this, seriesDataTable = chart.getDataTable(options), dataTables = data ? [data] : ( // Use either dataTable from series options or from the chart seriesDataTable.length ? seriesDataTable : chart.dataTable), dataColumnKeys = this.getDataColumnKeys(), mapping = options.dataMapping, keys = dataColumnKeys.slice(); // Extend the data column keys with the keys from the column assignment if (mapping) { Object.keys(mapping).forEach((key) => { pushUnique(keys, key); }); this.dataColumnKeys = keys; } dataTables.forEach((dataTable, dtIndex) => { // Resolve the data mapping const columns = keys .reduce((targetColumns, key) => { const mappingItem = mapping?.[key], srcColumns = dataTable.columns || {}, dtId = dataTable.id, column = isString(mappingItem) ? // String definition points directly to a column id // on the first data table (dtIndex === 0 && srcColumns[mappingItem]) : // Object definition, check for matching data table // and column id/index ((mappingItem?.dataTable || 0) === dtIndex || (dtId && mappingItem?.dataTable === dtId)) && (isNumber(mappingItem?.column) ? Object.values(srcColumns)[mappingItem.column] : srcColumns[mappingItem?.column || key]); if (column) { targetColumns[key] = column; } return targetColumns; }, {}); // If a DataTable is passed and no column assignment is set, use it // directly if (mapping || dataTable) { // Set the columns table.setColumns(columns); } // If a DataTable is passed directly by reference, bind events to // keep the series updated if (dataTable.isDataTable) { this.bindDataTableEvents(dataTable, columns); } }); } /** * Bind data table events to keep the series updated when changes occur to * the data table. * * @internal */ bindDataTableEvents(dataTable, columns) { if (this.hasBoundDataTableEvents) { return; } const { chart, eventsToUnbind } = this, queueRedraw = () => { clearTimeout(chart.redrawTimeout); chart.redrawTimeout = setTimeout(() => chart.renderer && chart.redraw(), 0); }; eventsToUnbind.push(addEvent(dataTable, 'afterSetRows', (e) => { const rowIndex = e.rowIndex; if (isNumber(rowIndex)) { const row = DataTableCore.prototype.getRowObject.call({ columns }, rowIndex), point = this.data[rowIndex]; if (row) { if (this.currentDataGrouping) { // Set data with immediate redraw because it // destroys points this.setData(); } else { if (point) { point.update(row, false); } else { this.addPoint(row, false); } queueRedraw(); }