highcharts
Version:
JavaScript charting framework
1,218 lines • 160 kB
JavaScript
/* *
*
* (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();
}