Line
The best and most appreciated feature of LightningChart JS is the simple LineSeries.
This guide goes through most of the things you can do with it and how.


// Creation of a line series
const lineSeries = chart.addLineSeries()
Line series are actually PointLineAreaSeries with disabled area fill and point fill. This is mainly for performance reasons - this 1 class can be styled to many different use cases.
Adding data
There are many different ways you can add data to a Line series.
The most basic way is to specify 2 number arrays, 1 for X coordinates, and another for Y coordinates.
These arrays can be either primitive number arrays, or TypedArrays.
lineSeries.appendSamples({
xValues: [0, 1, 2],
yValues: [10, 12, 5]
})
Another common way of adding data is to read from a list of JavaScript objects:
lineSeries.appendJSON(
[
{ i: 0, voltage: 4.2 },
{ i: 1, voltage: 4.6 },
{ i: 2, voltage: 4.5 },
],
)
appendJSON will by default store all supplied data properties.
If you only want to load specific properties into the data set, then you should whitelist them:
lineSeries.appendJSON(data, { whitelist: ['index', 'value'] })
Data input can also be list of tuples:
lineSeries.appendJSON([
[0, 100],
[1, 108],
[2, 105]
])
A common user problem is that data is supplied as
stringinstead ofnumber- please confirm this if you are experiencing any strange issues.
console.log(typeof y) // ---> 'number'
Any value can also be supplied as a single number to use same value for all samples:
lineSeries.appendSamples({
yValues: [10, 12, 5],
size: 5
})
You can also mark data properties for automatic incrementation:
const lineSeries = chart.addLineSeries({
schema: {
index: { auto: { start: 0, step: 1 } }
}
})
.appendSamples({ yValues: [10, 12, 5] })
Schema and data mapping
Both schema and data mapping concepts are technically optional (as in you can ignore them, supply data to series and most of the times it will work). However, depending on the use case you may need to understand what they are and how to configure them.
Schema defines what data can be stored in a series or data set
const series = chart.addLineSeries({
schema: {
index: { auto: true },
x: { pattern: 'progressive' },
y: { storage: Float32Array },
pointSize: {}
}
})
If schema is not defined, it is automatically configured to match first incoming data. First pushed data can also expand the configured schema - meaning you can configure known data properties, and still be able to consume other data properties that you may not know beforehand.
Data mapping specifies how data properties of the schema should be used
series.setDataMapping({ x: 'index', y: 'y', size: 'pointSize' })
Series and data sets can contain any number of different data properties. The above example says: "use 'index' as X, 'y' as Y and 'pointSize' as size of points".
If data mapping is not defined, it is automatically configured by looking at what is available and what are the names of data properties. While this works most of the time, explicit data mapping configuration is recommended to not leave such a critical configuration up to chance.
Data mapping can be changed at any point during runtime.
It must include at least x and y. Optionally also color, size, lookupValue and rotation can be specified.
Data patterns
Oftentimes, XY Series are used to visualize data that is progressive in 1 dimension. For example, in date-time use cases, the data is generally in increasing time order. LightningChart JS series are heavily optimized for this particular scenario, where data is ordered.
Data patterns should be specified as part of schema:
const series = chart.addLineSeries({
schema: {
x: { pattern: 'progressive' },
y: { pattern: null }
}
})
If data pattern is not specified in application code, LightningChart JS will automatically check if the data seems progressive and if positive will automatically enable the progressive optimizations. This will result in a warning displayed in the console.
Using automatic data pattern detection is OK during initial testing, proof of concept development, etc. But for long term, it is recommended to explicitly specify the schema and data patterns.
If you want to make sure you don't miss any important configuration due to them being filled automatically, you may want to enable strict mode:
// With strict mode, any configuration that is not explicitly specified will immediately throw an Error
const series = chart.addLineSeries({ strictMode: true })
Using timestamps
PointLineAreaSeries and DataSetXY APIs can directly consume timestamp data as any of following types:
- Date time string
- Date object
- UTC timestamp
number(e.g.Date.getTime())
Features other than PointLineAreaSeries/DataSetXY currently only support direct input as UTC timestamp numbers!
// Example 1, date time string
lineSeries.appendSample({
x: '2022-12-31T22:00:00.000Z',
y: Math.random()
})
// Example 2, Date object
lineSeries.appendSample({
x: new Date(2023, 0, 1),
y: Math.random()
})
// Example 3, UTC timestamp number
setInterval(() => {
lineSeries.appendSample({
x: Date.now(),
y: Math.random(),
})
}, 100)
When using timestamps, you generally want to also setup a Date-Time Axis.
Displaying nanosecond timestamps requires some extra configurations. See Nanosecond timestamps for more details.
Data cleaning / maximum memory use
In streaming use cases, memory usage can be limited by configuring the maximum number of samples that are retained. After this number is reached, the oldest samples will be dropped to keep only the max number of samples in memory.
// Example, keep max 1 million samples.
lineSeries.setMaxSampleCount(1_000_000)
Alternatively, if you are uncertain what value to use and don't want to allocate too much memory up-front, you can start small and automatically increase the buffer size as samples flow in:
lineSeries.setMaxSampleCount({ mode: 'auto', max: 10_000_000 })
This would first allocate only small amount of memory, progressively increase memory allocation as samples come in until eventually limiting sample count to 10 million.
String data types
The main purpose of LightningChart JS is to visualize numerical data. It is possible, however, to load data that is string typed and show it in cursors or custom interactions. This is a two step process:
- Specify data property as non-numeric
- When accessing the data property via library API, the type must be cast from number to string
// Example, load non numeric data and display it in cursor
const series = chart
.addPointLineAreaSeries({
schema: {
x: { pattern: 'progressive' },
y: { pattern: null },
extra: { nonNumeric: true },
},
})
.appendSamples({
x: [0, 1, 2, 3, 4],
y: [0, 10, 5, 8, 9],
extra: ['a', 'b', 'c', 'd', 'e'],
})
.setCursorFormattingOverride((hit, before) => {
return [...before, ['Extra', '', String(hit.sample['extra'])]]
})
Non-numeric data can't be used in the actual visualization at all, but it can be accessed in cursors, series interaction handlers with solve nearest / read back APIs.
Changing stroke color
const fillRed = new SolidFill({ color: ColorRGBA(255, 0, 0, 255) })
lineSeries.setStrokeStyle((stroke) => stroke.setFillStyle(fillRed))
Color can also be defined in a handful of other ways.
const fillBlue = new SolidFill({ color: ColorHEX('#0000ff') })
const fillGreen = new SolidFill({ color: ColorCSS('green') })
For more details about style API, please see Styles, colors and fonts.
Changing stroke thickness
lineSeries.setStrokeStyle((stroke) => stroke.setThickness(1))
By setting thickness to exactly -1 you can enable "primitive line rendering".
This is very light on GPU utilization (works well on weak machines), but results in lines being 1 pixel thick and not very smooth.
// Enable primitive line rendering - 1 px thick and considerably lighter on GPU.
lineSeries.setStrokeStyle((stroke) => stroke.setThickness(-1))
Dashed line series
lineSeries.setStrokeStyle((stroke) => new DashedLine({
fillStyle: stroke.getFillStyle(),
thickness: stroke.getThickness(),
pattern: StipplePatterns.Dashed,
}))
Connecting to non-default axis
If your ChartXY has several axes, then you can specify which axis the series should be connected to like this:
const lineSeries = chart.addLineSeries({
xAxis: myXAxis,
yAxis: myYAxis,
})
Data gaps
If your data has gaps, which should not be connected in the line visualization, you can create a gap with a NaN Y value:
lineSeries.appendSamples({
yValues: [1, 2, NaN, 2, 1]
}) // ---> line from { x: 0, y: 1 } to { x: 1, y: 2 }, then GAP, starts again from { x: 3, y: 2 }.
Please note that the value has to be specifically NaN - undefined, null or any other value will not have the same effect.
Solve nearest from location
// Example 1, when user clicks on chart, log closest data point to console
chart.seriesBackground.addEventListener('click', event => {
const nearest = lineSeries.solveNearest(event)
console.log(nearest)
})
// Example 2, solve nearest data point from an axis coordinate
const locationAxis = { x: 10, y: 10 }
const nearest = lineSeries.solveNearest(locationAxis)
console.log(nearest)
Solve results include just about all possible information about the nearest data point: x, y, individual colors/lookup values if relevant, the sample index as well as any other data properties of that sample even if they are not mapped to the series.
Interactions with data points
All series support tracking user interactions:
lineSeries.addEventListener('click', (event, hit) => {
// hit.x
// hit.y
console.log(hit)
})
The best way to see what events are available is to have type checking in your development environment, and start writing addEventListener (they are strongly typed). Most commonly used events are:
'click''contextmenu''dblclick''pointerenter''pointermove''pointerleave''pointerdown''pointerup'
In above example, the hit variable will include just about all possible information about the interacted data point: x, y, individual colors/lookup values if relevant, the sample index as well as any other data properties of that sample even if they are not mapped to the series.
Individual colors
To color line with per-data point colors:
- Load color data (as numbers or
Colorobjects) to a data property. - Map above data property to
"color" - Set fill style to
IndividualPointFill.
const lineSeries = chart.addLineSeries({
schema: {
x: { auto: true }
},
})
.setStrokeStyle((stroke) => stroke.setFillStyle(new IndividualPointFill()))
.setDataMapping({ x: 'x', y: 'yValues', color: 'colors' })
.appendSamples({
yValues: [10, 12, 9],
colors: [0xff0000ff, 0xff00ff00, 0xff00ff00], // red, green, green
})


Colors can also be supplied as Color objects created using one of the many Color factories (ColorRGBA, ColorHEX, etc.).
However, for performance reasons you should avoid creating a large number of individual Color objects.
const colorRed = new SolidFill({ color: ColorRGBA(255, 0, 0) })
const colorGreen = new SolidFill({ color: ColorRGBA(0, 255, 0) })
lineSeries
.appendSamples({
yValues: [10, 12, 9],
// This is fine (reuse same color objects)
colors: [colorRed, colorGreen, colorGreen],
})
Color by lookup table
Alternative approach to individual colors, you can give each data point a number value, and specify rules for getting a color from any number:
const lineSeries = chart.addLineSeries({
schema: {
x: { auto: true }
},
})
.setStrokeStyle((stroke) => stroke.setFillStyle(new PalettedFill({
lookUpProperty: 'value',
lut: new LUT({
interpolate: true,
steps: [
{ value: 0, color: ColorCSS('red') },
{ value: 100, color: ColorCSS('green') },
]
})
})))
.setDataMapping({ x: 'x', y: 'yValues', lookupValue: 'lookupValues' })
.appendSamples({
yValues: [10, 12, 9],
lookupValues: [0, 100, 50]
})
().add(chart)


LUT has also a percentageValues option, which you can use if you want to use % values of the present min-max lookup value range, rather than specific value steps:
new LUT({
percentageValues: true,
interpolate: true,
steps: [
// 0 = 0 %, 1 = 100%
{ value: 0, color: ColorCSS('red') },
{ value: 1, color: ColorCSS('green') },
]
})
Color by X or Y
Instead of using lookup values in data set, you can also use X or Y coordinates directly, by changing the lookUpProperty value in PalettedFill.
new PalettedFill({
lookUpProperty: 'x', // 'x' or 'y'
lut: new LUT({
interpolate: true,
steps: [
{ value: 0, color: ColorCSS('red') },
{ value: 100, color: ColorCSS('green') },
]
})
})
Color by gradient
More alternatives to dynamic coloring - you can also define a gradient (linear or radial), to color the line stroke.
const lineSeries = chart.addLineSeries()
.setStrokeStyle((stroke) => stroke.setFillStyle(
new LinearGradientFill({
// down -> up
angle: 0,
stops: [
{ offset: 0, color: ColorCSS('red') },
{ offset: 1, color: ColorCSS('green') },
],
})
))


Edit samples data
See also Affine transforms
alterSamplesStartingFrom
This method allows you to specify a starting location in the existing data set with a sample index. This is a simple counter that points to the n:th sample that has been pushed to the data set, in the order of appearance.
lineSeries.appendSamples({ yValues: [0, 1, 2, 3, 4, 5] })
lineSeries.alterSamplesStartingFrom(1, { yValues: [10, 11] })
// ---> Data set = [0, 10, 11, 3, 4, 5]
alterSamples lets you modify any subset of existing data properties.
It also supports automatically appending data if you attempt to modify samples that would come after the existing ones.
// Example, load same value for all altered samples
lineSeries.alterSamplesStartingFrom(1, { size: 5 }, { count: 5 })
alterSamplesByIndex
Alter samples with specific sample index. Can be used same as alterSamplesStartingFrom but for samples that are not in continuous order.
lineSeries.appendSamples({ yValues: [0, 1, 2, 3, 4, 5] })
lineSeries.alterSamplesByIndex([0, 2], { yValues: [10, 11] })
// ---> Data set = [10, 1, 11, 3, 4, 5]
// Example, load same value for all altered samples
lineSeries.alterSamplesByIndex([0, 2], { size: 5 })
alterSamplesByMatch
This method allows you to arbitrarily edit any number of existing samples without knowing their sample index. Instead, samples are selected by matching value of a specific data property. Generally, this would be some property that is unique between samples, such as X or ID.
lineSeries.appendSamples({
xValues: [0, 1, 2, 3, 4],
yValues: [100, 101, 102, 103, 104],
})
lineSeries.alterSamplesByMatch(
'xValues',
[2, 4], // Edit samples x=2 and x=4
{
yValues: [200, 201],
}
)
// Example, load same value for all altered samples
lineSeries.alterSamplesByMatch('xValues', [2, 4], { size: 5 })
fill
This method allows you to load a single value for all existing samples in the data set:
// Set point size of all samples to 5 pixels
lineSeries.fill({ size: 5 })
Data storage optimization
By default, all data is stored as 64-bit floats. In many use cases, full 64-bit resolution is not necessary or even completely redundant (if source data has less precision). It is possible to individually configure each data properties data storage format:
const lineSeries = chart.addLineSeries({
schema: {
x: { storage: Float64Array },
y: { storage: Float32Array },
color: { storage: Uint32Array },
}
})
Available options are:
Float64ArrayFloat32ArrayInt32ArrayUint32ArrayInt16ArrayUint16ArrayInt8ArrayUint8ArrayUint8ClampedArray
Reducing number precision results in less memory usage being required to load same amount of data.
Some browsers store typed array outside JS heap (which is commonly used to debug application memory usage). Thus, LightningChart JS memory usage is generally not perceivable by looking at JS heap size. Instead, developer tools memory snapshots are a better tool.
Avoiding input data duplication
In order to feed data into a series or data set, the data must first be loaded to the JavaScript application. Later, the data is placed into LightningChart JS internal data storage. It is possible to completely avoid any data duplication between these two steps, effectively reducing memory consumption by up to 2x.
If following conditions are met, then input data can be directly referenced by the library without making a copy:
- The data set is empty.
- Input data is supplied as Typed array.
- Max sample count is not configured, or exactly matches the number of incoming samples.
- Input data is of same Typed array variant as the configured storage format.
// Example of configured storage matching incoming data format
const series = chart.addLineSeries({
schema: {
x: { storage: Float64Array, ensureNoDuplication: true },
y: { storage: Float32Array, ensureNoDuplication: true }
}
})
let xValues: Float64Array
let yValues: Float32Array
series.appendSamples({
x: xValues,
y: yValues
})
ensureNoDuplication is not necessary in above example.
If it is set to true, then the application will throw an error if data would have to be duplicated.
If your application requires referencing the same data that is visualized with LightningChart JS, the most efficient approach is to not keep any data cache in the application itself, but read back the data from LightningChart when required.
Or if you can ensure that the input data is not duplicated inside the library, that is another good approach.
Multi-threading
LightningChart JS line series can utilize several CPU cores in parallel to perform even faster and more efficiently. This is not enabled by default.
Requirements to use multi-threading:
- The JavaScript runtime must support usage of
SharedArrayBuffer- Please refer to https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer for more information.
- Data must either be directly supplied with
SharedArrayBuffer-backed Typed arrays, or the LC dataset must be configured to do this under the hood withuseSharedArrayBuffersoption.
When is multi-threading useful?
- You have several line series and want faster loading times
- With multi-threading, loading times become generally 6x faster.
- You want to optimize the main thread for user interactivity at all times, even while large datasets are being loaded, or user is zooming in/out.
- When using multi-threading, CPU usage stays low regardless how massive datasets you are loading. The heavy work will be done on other CPU core(s).
How to enable multi-threading?
It's really simple:
const lineSeries = chart.addLineSeries({
useSharedArrayBuffers: true
})
By enabling the useSharedArrayBuffers flag, the LC DataSet will automatically ensure that its internal dataset is based on SharedArrayBuffers, and whenever there is a suitable moment to utilize multi-threading the charts will do so.
Async rendering
After enabling multi-threading, there will be some differences in how the charts behave. Normally, all LightningChart JS functionality is completely synchronous - if you do something, it will be shown next frame.
With multi-threading enabled, render updates can be postponed to following frames. This has two main implications:
- During this short time, the chart will display a preview of the data. This is essentially a lossy, quick downsample of the data, that is soon after replaced with the proper dataset after being prepared for rendering.
requestAnimationFramecan't be used to know when the chart has updated. Instead, you need to use therenderframeevent:
chart.engine.addEventListener('renderframe', (event) => {
if (!event.asyncRenderingPending) {
// Chart has finished all sync and async updates
}
})
Input data duplication
When useSharedArrayBuffers: true is used, there are additional requirements for charts to not need to duplicate the user-supplied data. Namely, the user data must be backed by SharedArrayBuffer to begin with.
Most of the times, it is not possible to fetch remote data to frontend as SharedArrayBuffers without a duplication step in between. If you want to confirm this for your use case, please contact our tech team.
Worker allocation
By default, multi-threading will create as many web workers as there are series requiring heavy CPU work, but no more than value of navigator.hardwareConcurrency (so things will work regardless of number of CPU cores).
If you need to limit amount of webworkers utilized by LightningChart JS, you can specify a custom cap:
const lc = lightningChart({
webWorkers: {
maxWorkers: 4,
},
})
Read back data
Existing data in a PointLineAreaSeries can be read back using readback method:
lineSeries.readback()
// xValues: TypedArray
// yValues: TypedArray
// iSampleFirst: number
// lookupValues?: TypedArray
// colors?: TypedArray
// ids?: TypedArray
// sizes?: TypedArray
// rotations?: TypedArray
With progressive data sets you can also conveniently read back only data in given range:
series.readBack({ onlyInRange: chart.xAxis.getInterval() })
series.readBack({ onlyInRange: { start: 0, end: 100 } })
series.readBack only returns data that is actively used by the series (according to its data mapping).
The full data set can be read via DataSetXY:
lineSeries.getDataSet().readBack()
// data: Record<string, TypedArray>
// iSampleFirst: number
Separating data sets from series
In basic use cases, the recommended way to display data in a chart is to just
- Create series
- Push data into the series
Under the hood, this automatically creates a DataSetXY and sets it as the active data set.
Conversely, you can create this data set object in your application code and manage it yourself:
const dataSet = new DataSetXY({
schema: {
x: { auto: true },
y1: {},
y2: {}
}
})
series.setDataSet(dataSet)
All APIs that revolve around inputting data, such as schema, appendSamples, setMaxSampleCount etc.
are available on both series and DataSetXY.
Separating the data set from series like this allows some potentially useful functionalities:
Shared timestamps or other data
const series1 = chart.addLineSeries()
.setDataSet(dataSet, { x: 'x', y: 'y1' }) // <- data mapping
const series2 = chart.addLineSeries()
.setDataSet(dataSet, { x: 'x', y: 'y2' })
While you could very easily do this without using DataSetXY at all, that would result in multiple data sets being created and X data storage being duplicated! Especially if you have large number of channels that have shared timestamps, using a shared data set is much more efficient!
Switching between axis types
It is currently not possible to move a series from an axis to another. But if you separate data set from series, then you can create series on both axes (reusing the same data set), and keep the other hidden.
Scaling, offsetting and transforming coordinates
It is possible to configure the library to apply an affine transform (offset + scaling) to rendered data points. This is very performant, but only affects rendered output. It can result in discrepancies in cursors and automatic axis scrolling.
// Example syntax, multiple Y coordinates by 10 and subtract 5
series.setRenderTransform({
x: { scaling: 1, offset: 0 },
y: { scaling: 10, offset: -5 }
})