An exceptionally fast, tiny (< 20 KB min) time series & line chart (MIT Licensed)
ฮผPlot is a fast, memory-efficient time series & line chart based on Canvas 2D; from a cold start it can create an interactive chart containing 150,000 data points in 50ms, scaling linearly at ~4,000 pts/ms. In addition to fast initial render, the zooming and cursor performance is by far the best of any similar charting lib; at < 20 KB, it's likely the smallest and fastest time series plotter that doesn't make use of context-limited WebGL shaders or WASM, both of which have much higher startup cost and code size.
166,650 point bench: https://leeoniya.github.io/uPlot/bench/uPlot.html
- Multiple series w/toggle
- Multiple y-axes, scales & grids
- Temporal or numeric x-axis
- Line & Area styles (stroke, fill, width, dash)
- Zoom with auto-rescale
- Legend with live values
- Support for IANA Time Zone Names & DST
- Support for missing data
- Cursor sync for multiple charts
- Focus closest series
- Data streaming (live update)
- High / Low bands
In order to stay lean, fast and focused the following features will not be added:
- No data parsing, aggregation, summation or statistical processing - just do it in advance. e.g. https://simplestatistics.org/, https://www.papaparse.com/
- No transitions or animations - they're always pure distractions.
- No DOM measuring; uPlot does not know how much space your dynamic labels & values will occupy, so requires explicit sizing and/or some CSS authoring.
- No stacked series or line smoothing. See links for how these are each terrible at actually communicating information.
- Probably no drag scrolling/panning. Maintaining good perf with huge datasets would require a lot of extra code & multiple
<canvas>elements to avoid continuous redraw and rescaling on each dragged pixel. However, since uPlot's performance allows rendering of very wide canvases, they can be scrolled naturally with CSS'soverflow-x: autoapplied to a narrower containing element. Pagination of data also works well.
- Installation
- Data Format
- Basics
- High/Low Bands
- Series, Scales, Axes, Grid
- Multiple Scales & Axes
- Scale Opts
- Axis & Grid Opts
- WIP: #48
<link rel="stylesheet" href="src/uPlot.css">
<script src="dist/uPlot.iife.min.js"></script>let data = [
[1546300800, 1546387200], // x-values (timestamps)
[ 35, 71], // y-values (series 1)
[ 90, 15], // y-values (series 2)
];uPlot expects a columnar data format as shown above.
- x-values must be numbers, unique, and in ascending order.
- y-values must be numbers (or
nulls for missing data). - x-values and y-values arrays must be of equal lengths >= 2.
By default, x-values are assumed to be unix timestamps (seconds since 1970-01-01 00:00:00) but can be treated as plain numbers via scales.x.time = false.
JavaScript uses millisecond-precision timestamps, but this precision is rarely necessary on calendar-aware time: true scales/plots, which honor DST, timezones, leap years, etc.
For sub-second periods, it's recommended to set time: false and simply use ms offsets from 0.
If you truly need calendar-aware ms level precision, simply provide the timestamps as floats, e.g. 1575354886.419.
More info....
This format has implications that can make uPlot an awkward choice for multi-series datasets which cannot be easily aligned along their x-values.
If one series is data-dense and the other is sparse, then the latter will need to be filled in with mostly null y-values.
If each series has data at arbitrary x-values, then the x-values array must be augmented with all x-values, and all y-values arrays must be augmented with nulls, potentially leading to exponential growth in dataset size, and a structure consisting of mostly nulls.
This does not mean that all series must have identical x-values - just that they are alignable. For instance, it is possible to plot series that express different time periods, because the data is equally spaced.
Before choosing uPlot, ensure your data can conform to these requirements.
let opts = {
title: "My Chart",
id: "chart1",
class: "my-chart",
width: 800,
height: 600,
series: [
{},
{
// initial toggled state (optional)
show: true,
spanGaps: false,
// in-legend display
label: "RAM",
value: (self, rawValue) => "$" + rawValue.toFixed(2),
// series style
stroke: "red",
width: 1,
fill: "rgba(255, 0, 0, 0.3)",
dash: [10, 5],
}
],
};
let uplot = new uPlot.Line(opts, data, document.body);idandclassare optional HTML attributes to set on the chart's container<div>(uplot.root).widthandheightare required dimensions in logical [CSS] pixels of the plotting area & axes, but excludingtitleorlegenddimensions (which can be variable based on user CSS).spanGapscan be set totrueto connectnulldata points.- For a series to be rendered, it must be specified in the opts; simply having it in the data is insufficient.
- All series' options are optional;
labelwill default to "Value" andstrokewill default to "black". - Series' line
widthis specified in physical [device] pixels (e.g. on high-DPI displays with a pixel ratio = 2,width: 1will draw a line with an effective width of 0.5 logical [CSS] pixels). stroke,width,fill, anddashmap directly to Canvas API's ctx.strokeStyle, ctx.lineWidth, ctx.fillStyle, and ctx.setLineDash.
High/Low bands are defined by two adjacent data series in low,high order and matching opts with series.band = true.
const opts = {
series: [
{},
{
label: "Low",
fill: "rgba(0, 255, 0, .2)",
band: true,
},
{
label: "High",
fill: "rgba(0, 255, 0, .2)",
band: true,
},
],
};uPlot's API strives for brevity, uniformity and logical consistency.
Understanding the roles and processing order of data, series, scales, and axes will help with the remaining topics.
The high-level rendering flow is this:
datais the first input into the system.seriesholds the config of each dataset, such as visibility, styling, labels & value display in the legend, and thescalekey along which they should be drawn. Implicit scale keys arexfor thedata[0]series andyfordata[1..N].scalesreflect the min/max ranges visible within the view. All view range adjustments such as zooming and pagination are done here. If not explicitly set via opts,scalesare automatically initialized using theseriesconfig and auto-ranged using the provideddata.axesrender the ticks, values, labels and grid along theirscale. Tick & grid spacing, value granularity & formatting, timezone & DST handling is done here.
You may have noticed in the previous examples that series and axes arrays begin with {}.
This represents options/overrides for the x series and axis.
They are required due to the way uPlot sets defaults:
-
data[0],series[0]andaxes[0]represent & inheritxdefaults, e.g:"x"scale w/auto: false- temporal
- hz orientation, bottom position
- larger minimum tick spacing
-
data[1..N],series[1..N]andaxes[1..N]represent & inheritydefaults, e.g:"y"scale w/auto: true- numeric
- vt orientation, left position
- smaller minimum tick spacing
While somewhat unusual, keeping x & y opts in flat arrays [rather than splitting them] serves several purposes:
- API & structural uniformity. e.g.
series[i]maps todata[i] - Hooks recieve an unambiguous
iinto the arrays without needing futher context - Internals don't need added complexity to conceal the fact that everything is merged & DRY
Series with differing units can be plotted along additional scales and display corresponding y-axes.
- Use the same
series.scalekey. - Optionally, specify an additional
axiswith thescalekey.
let opts = {
series: [
{},
{
label: "CPU",
stroke: "red",
scale: "%",
value: (self, rawValue) => rawValue.toFixed(1) + "%",
}
{
label: "RAM",
stroke: "blue",
scale: "%",
value: (self, rawValue) => rawValue.toFixed(1) + "%",
},
{
label: "TCP",
stroke: "green",
scale: "mb",
value: (self, rawValue) => rawValue.toFixed(2) + "MB",
},
],
axes: [
{},
{
scale: "%",
values: (self, ticks) => ticks.map(rawValue => rawValue.toFixed(1) + "%"),
},
{
scale: "mb",
values: (self, ticks) => ticks.map(rawValue => rawValue.toFixed(2) + "MB"),
side: 1,
grid: {show: false},
},
],
};sideis the where to place the axis (0: top, 1: right, 2: bottom, 3: left).
Sometimes it's useful to provide an additional axis to display alternate units, e.g. ยฐF / ยฐC. This is done using dependent scales.
let opts = {
series: [
{},
{
label: "Temp",
stroke: "red",
scale: "F",
},
],
axes: [
{},
{
scale: "F",
values: (self, ticks) => ticks.map(rawValue => rawValue + "ยฐ F"),
},
{
scale: "C",
values: (self, ticks) => ticks.map(rawValue => rawValue + "ยฐ C"),
side: 1,
grid: {show: false},
}
],
scales: {
"C": {
from: "F",
range: (self, fromMin, fromMax) => [
(fromMin - 32) * 5/9,
(fromMax - 32) * 5/9,
],
}
},fromspecifies the scale on which this one depends.rangeconvertsfrom's min/max into this one's min/max.
If a scale does not need auto-ranging from the visible data, you can provide static min/max values. This is also a performance optimization, since the data does not need to be scanned on every view change.
let opts = {
scales: {
"%": {
auto: false,
range: [0, 100],
}
},
}The default x scale is temporal, but can be switched to plain numbers. This can be used to plot functions.
let opts = {
scales: {
"x": {
time: false,
}
},
}A scale's default distribution is linear distr: 1, but can be switched to indexed/evenly-spaced.
This is useful when you'd like to squash periods with no data, such as weekends.
Keep in mind that this will prevent logical temporal tick baselines such as start of day or start of month.
let opts = {
scales: {
"x": {
distr: 2,
}
},
}Most options are self-explanatory:
let opts = {
axes: [
{},
{
show: true,
label: "Population",
labelSize: 30,
labelFont: "bold 12px Arial",
font: "12px Arial",
gap: 5,
size: 50,
stroke: "red",
grid: {
show: true,
stroke: "#eee",
width: 2,
dash: [],
},
tick: {
show: true,
stroke: "#eee",
width: 2,
dash: [],
size: 10,
}
}
]
}size&labelSizerepresent the perpendicular dimensions assigned tovaluesandlabelsDOM elements, respectively. In the above example, the full width of this y-axis would be 30 + 50; for an x-axis, it would be its height.gapis the space between axis ticks andvalues.
Customizing the tick/grid spacing, value formatting and granularity is somewhat more involved:
let opts = {
axes: [
{
space: 40,
incrs: [
// minute divisors (# of secs)
1,
5,
10,
15,
30,
// hour divisors
60,
60 * 5,
60 * 10,
60 * 15,
60 * 30,
// day divisors
3600,
// ...
],
values: [
[3600 * 24 * 365, "{YYYY}", 7, "{YYYY}" ],
[3600 * 24 * 28, "{MMM}", 7, "{MMM}\n{YYYY}" ],
[3600 * 24, "{M}/{D}", 7, "{M}/{D}\n{YYYY}" ],
[3600, "{h}{aa}", 4, "{h}{aa}\n{M}/{D}" ],
[60, "{h}:{mm}{aa}", 4, "{h}:{mm}{aa}\n{M}/{D}" ],
[1, "{h}:{mm}:{ss}{aa}", 4, "{h}:{mm}:{ss}{aa}\n{M}/{D}"],
],
// ticks:
}
],
}spaceis the minumum space between adjacent ticks; a smaller number will result in smaller selected divisors. can also be a function of the form(self, scaleMin, scaleMax, dim) => spacewheredimis the dimension of the plot along the axis in CSS pixels.incrsare divisors available for segmenting the axis to produce ticks. can also be a function of the form(self) => divisors.valuescan be:- a function with the form
(self, ticks, space) => valueswhereticksis an array of raw values along the axis' scale,spaceis the determined tick spacing in CSS pixels andvaluesis an array of formated tick labels. - array of tick formatters with breakpoints. more format details can be found in the source: https://github.com/leeoniya/uPlot/blob/master/src/opts.js#L110
- a function with the form
Benchmarks done on a ThinkPad T480S:
- Windows 10 x64, 82.0.4055.0 (Developer Build) (64-bit)
- Core i5-8350U @ 1.70GHz, 8GB RAM
- Intel HD 620 GPU, 2560x1440 res
| lib | size | done | js,rend,paint,sys | heap peak,final | interact (10s) | | -------------- | ------- | ------- | ----------------- | --------------- | ------------------- | | uPlot | 23 KB | 57 ms | 82 6 3 73 | 13 MB 3 MB | 226 551 177 307 | | Flot | 494 KB | 324 ms | 195 7 4 305 | 25 MB 12 MB | --- | | dygraphs | 125 KB | 155 ms | 252 5 4 175 | 93 MB 53 MB | 2296 327 288 838 | | Chart.js-next | 244 KB | 200 ms | 298 6 4 112 | 45 MB 20 MB | 3415 41 98 6342 | | CanvasJS | 459 KB | 314 ms | 398 5 4 103 | 40 MB 26 MB | 3157 871 291 612 | | LightningChart | 904 KB | --- ms | 490 3 3 59 | 25 MB 14 MB | 9943 28 42 130 | | jqChart | 280 KB | 472 ms | 662 8 3 97 | 91 MB 56 MB | 1393 446 267 634 | | Highcharts | 286 KB | --- ms | 704 9 2 79 | 49 MB 19 MB | 2076 858 220 403 | | Chart.js | 245 KB | 675 ms | 760 5 4 182 | 85 MB 54 MB | 5550 6 13 4142 | | ECharts | 752 KB | --- ms | 769 6 9 1075 | 113 MB 73 MB | 2397 79 50 7925 | | ApexCharts | 447 KB | 1265 ms | 2367 32 3 67 | 161 MB 89 MB | 2147 288 34 234 | | ZingChart | 707 KB | 5976 ms | 6393 9 1 56 | 206 MB 174 MB | --- | | amCharts | 1100 KB | 5309 ms | 6498 54 16 88 | 289 MB 227 MB | 6627 1436 277 688 |
sizeincludes the lib itself plus any dependencies required to render the benchmark, e.g. Moment, jQuery, etc.- Flot does not make available any minified assets and all their examples use the uncompressed sources; they also use an uncompressed version of jQuery :/
TODO (all of these use SVG, so performance should be similar to Highcharts):
- Chartist.js
- d3-based
- C3.js
- dc.js
- Plotly
- MetricsGraphics
- rickshaw
- Dan Vanderkam's dygraphs was a big inspiration; in fact, my stale pull request #948 was a primary motivator for ฮผPlot's inception.
- Adam Pearce for #15 - remove redundant lineTo commands.