Add normalized option (#7538)

Add normalized option to time scales
This commit is contained in:
Ben McCann 2020-07-07 04:50:53 -07:00 committed by GitHub
parent 6bd5ad5518
commit 4cc3079e65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 218 additions and 237 deletions

View File

@ -2,7 +2,7 @@
title: Time Series Axis
---
The time series scale extends from the time scale and supports all the same options. However, for the time series scale, each data point is spread equidistant. Also, the data indices are expected to be unique, sorted, and consistent across datasets.
The time series scale extends from the time scale and supports all the same options. However, for the time series scale, each data point is spread equidistant.
## Example

View File

@ -4,6 +4,24 @@ title: Performance
Chart.js charts are rendered on `canvas` elements, which makes rendering quite fast. For large datasets or performance sensitive applications, you may wish to consider the tips below.
## Data structure and format
### Parsing
Provide prepared data in the internal format accepted by the dataset and scales and set `parsing: false`. See [Data structures](data-structures.md) for more information.
### Data normalization
Chart.js is fastest if you provide data with indices that are unique, sorted, and consistent across datasets and provide the `normalized: true` option to let Chart.js know that you have done so. Even without this option, it can sometimes still be faster to provide sorted data.
### Decimation
Decimating your data will achieve the best results. When there is a lot of data to display on the graph, it doesn't make sense to show tens of thousands of data points on a graph that is only a few hundred pixels wide.
There are many approaches to data decimation and selection of an algorithm will depend on your data and the results you want to achieve. For instance, [min/max](https://digital.ni.com/public.nsf/allkb/F694FFEEA0ACF282862576020075F784) decimation will preserve peaks in your data but could require up to 4 points for each pixel. This type of decimation would work well for a very noisy signal where you need to see data peaks.
Line charts are able to do [automatic data decimation during draw](#automatic-data-decimation-during-draw), when certain conditions are met. You should still consider decimating data yourself before passing it in for maximum performance since the automatic decimation occurs late in the chart life cycle.
## Tick Calculation
### Rotation
@ -30,10 +48,6 @@ new Chart(ctx, {
});
```
## Provide ordered data
If the data is unordered, Chart.js needs to sort it. This can be slow in some cases, so its always a good idea to provide ordered data.
## Specify `min` and `max` for scales
If you specify the `min` and `max`, the scale does not have to compute the range from the data.
@ -59,19 +73,7 @@ new Chart(ctx, {
});
```
## Data structure and format
Provide prepared data in the internal format accepted by the dataset and scales and set `parsing: false`. See [Data structures](data-structures.md) for more information.
## Data Decimation
Decimating your data will achieve the best results. When there is a lot of data to display on the graph, it doesn't make sense to show tens of thousands of data points on a graph that is only a few hundred pixels wide.
There are many approaches to data decimation and selection of an algorithm will depend on your data and the results you want to achieve. For instance, [min/max](https://digital.ni.com/public.nsf/allkb/F694FFEEA0ACF282862576020075F784) decimation will preserve peaks in your data but could require up to 4 points for each pixel. This type of decimation would work well for a very noisy signal where you need to see data peaks.
Line charts are able to do [automatic data decimation during draw](#automatic-data-decimation-during-draw), when certain conditions are met. You should still consider decimating data yourself before passing it in for maximum performance since the automatic decimation occurs late in the chart life cycle.
## Render Chart.js in a web worker (Chrome only)
## Parallel rendering with web workers (Chrome only)
Chome (in version 69) added the ability to [transfer rendering control of a canvas](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/transferControlToOffscreen) to a web worker. Web workers can use the [OffscreenCanvas API](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) to render from a web worker onto canvases in the DOM. Chart.js is a canvas-based library and supports rendering in a web worker - just pass an OffscreenCanvas into the Chart constructor instead of a Canvas element. Note that as of today, this API is only supported in Chrome.
@ -220,7 +222,7 @@ new Chart(ctx, {
});
```
### When transpiling with Babel, cosider using `loose` mode
## When transpiling with Babel, cosider using `loose` mode
Babel 7.9 changed the way classes are constructed. It is slow, unless used with `loose` mode.
[More information](https://github.com/babel/babel/issues/11356)

View File

@ -355,7 +355,7 @@ export default class BarController extends DatasetController {
let i, ilen;
for (i = 0, ilen = meta.data.length; i < ilen; ++i) {
pixels.push(iScale.getPixelForValue(me.getParsed(i)[iScale.axis]));
pixels.push(iScale.getPixelForValue(me.getParsed(i)[iScale.axis], i));
}
// Note: a potential optimization would be to skip computing this

View File

@ -49,8 +49,8 @@ export default class LineController extends DatasetController {
const index = start + i;
const point = points[i];
const parsed = me.getParsed(index);
const x = xScale.getPixelForValue(parsed.x);
const y = reset ? yScale.getBasePixel() : yScale.getPixelForValue(_stacked ? me.applyStack(yScale, parsed) : parsed.y);
const x = xScale.getPixelForValue(parsed.x, index);
const y = reset ? yScale.getBasePixel() : yScale.getPixelForValue(_stacked ? me.applyStack(yScale, parsed) : parsed.y, index);
const properties = {
x,
y,

View File

@ -451,7 +451,7 @@ class Chart {
scales[scale.id] = scale;
}
scale.init(scaleOptions);
scale.init(scaleOptions, options);
// TODO(SB): I think we should be able to remove this custom case (options.scale)
// and consider it as a regular scale part of the "scales"" map only! This would

View File

@ -938,9 +938,10 @@ export default class Scale extends Element {
* Returns the location of the given data point. Value can either be an index or a numerical value
* The coordinate (0, 0) is at the upper-left corner of the canvas
* @param {*} value
* @param {number} [index]
* @return {number}
*/
getPixelForValue(value) { // eslint-disable-line no-unused-vars
getPixelForValue(value, index) { // eslint-disable-line no-unused-vars
return NaN;
}

View File

@ -217,13 +217,13 @@ export default class TimeScale extends Scale {
this._unit = 'day';
/** @type {Unit=} */
this._majorUnit = undefined;
/** @type {object} */
this._offsets = {};
this._normalized = false;
}
init(options) {
const time = options.time || (options.time = {});
const adapter = this._adapter = new adapters._date(options.adapters.date);
init(scaleOpts, opts) {
const time = scaleOpts.time || (scaleOpts.time = {});
const adapter = this._adapter = new adapters._date(scaleOpts.adapters.date);
// Backward compatibility: before introducing adapter, `displayFormats` was
// supposed to contain *all* unit/string pairs but this can't be resolved
@ -231,7 +231,9 @@ export default class TimeScale extends Scale {
// missing formats on update
mergeIf(time.displayFormats, adapter.formats());
super.init(options);
super.init(scaleOpts);
this._normalized = opts.normalized;
}
/**
@ -574,13 +576,15 @@ export default class TimeScale extends Scale {
const metas = me.getMatchingVisibleMetas();
if (me._normalized && metas.length) {
return (me._cache.data = metas[0].controller.getAllParsedValues(me));
}
for (i = 0, ilen = metas.length; i < ilen; ++i) {
timestamps = timestamps.concat(metas[i].controller.getAllParsedValues(me));
}
// We can not assume data is in order or unique - not even for single dataset
// It seems to be somewhat faster to do sorting first
return (me._cache.data = _arrayUnique(timestamps.sort(sorter)));
return (me._cache.data = me.normalize(timestamps));
}
/**
@ -600,8 +604,16 @@ export default class TimeScale extends Scale {
timestamps.push(parse(me, labels[i]));
}
// We could assume labels are in order and unique - but let's not
return (me._cache.labels = _arrayUnique(timestamps.sort(sorter)));
return (me._cache.labels = me._normalized ? timestamps : me.normalize(timestamps));
}
/**
* @param {number[]} values
* @protected
*/
normalize(values) {
// It seems to be somewhat faster to do sorting first
return _arrayUnique(values.sort(sorter));
}
}

View File

@ -1,37 +1,34 @@
import TimeScale from './scale.time';
import {_arrayUnique, _lookupByKey} from '../helpers/helpers.collection';
import {_lookup} from '../helpers/helpers.collection';
import {isNullOrUndef} from '../helpers/helpers.core';
/**
* Linearly interpolates the given source `value` using the table items `skey` values and
* returns the associated `tkey` value. For example, interpolate(table, 'time', 42, 'pos')
* returns the position for a timestamp equal to 42. If value is out of bounds, values at
* index [0, 1] or [n - 1, n] are used for the interpolation.
* Linearly interpolates the given source `val` using the table. If value is out of bounds, values
* at index [0, 1] or [n - 1, n] are used for the interpolation.
* @param {object} table
* @param {string} skey
* @param {number} sval
* @param {string} tkey
* @param {number} val
* @param {boolean} [reverse] lookup time based on position instead of vice versa
* @return {object}
*/
function interpolate(table, skey, sval, tkey) {
const {lo, hi} = _lookupByKey(table, skey, sval);
function interpolate(table, val, reverse) {
let prevSource, nextSource, prevTarget, nextTarget;
// Note: the lookup table ALWAYS contains at least 2 items (min and max)
const prev = table[lo];
const next = table[hi];
if (reverse) {
prevSource = Math.floor(val);
nextSource = Math.ceil(val);
prevTarget = table[prevSource];
nextTarget = table[nextSource];
} else {
const result = _lookup(table, val);
prevTarget = result.lo;
nextTarget = result.hi;
prevSource = table[prevTarget];
nextSource = table[nextTarget];
}
const span = next[skey] - prev[skey];
const ratio = span ? (sval - prev[skey]) / span : 0;
const offset = (next[tkey] - prev[tkey]) * ratio;
return prev[tkey] + offset;
}
/**
* @param {number} a
* @param {number} b
*/
function sorter(a, b) {
return a - b;
const span = nextSource - prevSource;
return span ? prevTarget + (nextTarget - prevTarget) * (val - prevSource) / span : prevTarget;
}
class TimeSeriesScale extends TimeScale {
@ -44,6 +41,8 @@ class TimeSeriesScale extends TimeScale {
/** @type {object[]} */
this._table = [];
/** @type {number} */
this._maxIndex = undefined;
}
/**
@ -53,6 +52,7 @@ class TimeSeriesScale extends TimeScale {
const me = this;
const timestamps = me._getTimestampsForTable();
me._table = me.buildLookupTable(timestamps);
me._maxIndex = me._table.length - 1;
super.initOffsets(timestamps);
}
@ -77,9 +77,8 @@ class TimeSeriesScale extends TimeScale {
];
}
const table = [];
const items = [min];
let i, ilen, prev, curr, next;
let i, ilen, curr;
for (i = 0, ilen = timestamps.length; i < ilen; ++i) {
curr = timestamps[i];
@ -90,18 +89,7 @@ class TimeSeriesScale extends TimeScale {
items.push(max);
for (i = 0, ilen = items.length; i < ilen; ++i) {
next = items[i + 1];
prev = items[i - 1];
curr = items[i];
// only add points that breaks the scale linearity
if (prev === undefined || next === undefined || Math.round((next + prev) / 2) !== curr) {
table.push({time: curr, pos: i / (ilen - 1)});
}
}
return table;
return items;
}
/**
@ -122,7 +110,7 @@ class TimeSeriesScale extends TimeScale {
if (data.length && label.length) {
// If combining labels and data (data might not contain all labels),
// we need to recheck uniqueness and sort
timestamps = _arrayUnique(data.concat(label).sort(sorter));
timestamps = me.normalize(data.concat(label));
} else {
timestamps = data.length ? data : label;
}
@ -131,50 +119,25 @@ class TimeSeriesScale extends TimeScale {
return timestamps;
}
/**
* @param {number} value - Milliseconds since epoch (1 January 1970 00:00:00 UTC)
* @param {number} [index]
* @return {number}
*/
getPixelForValue(value, index) {
const me = this;
const offsets = me._offsets;
const pos = me._normalized && me._maxIndex > 0 && !isNullOrUndef(index)
? index / me._maxIndex : me.getDecimalForValue(value);
return me.getPixelForDecimal((offsets.start + pos) * offsets.factor);
}
/**
* @param {number} value - Milliseconds since epoch (1 January 1970 00:00:00 UTC)
* @return {number}
*/
getDecimalForValue(value) {
return interpolate(this._table, 'time', value, 'pos');
}
/**
* @return {number[]}
* @protected
*/
getDataTimestamps() {
const me = this;
const timestamps = me._cache.data || [];
if (timestamps.length) {
return timestamps;
}
const metas = me.getMatchingVisibleMetas();
return (me._cache.data = metas.length ? metas[0].controller.getAllParsedValues(me) : []);
}
/**
* @return {number[]}
* @protected
*/
getLabelTimestamps() {
const me = this;
const timestamps = me._cache.labels || [];
let i, ilen;
if (timestamps.length) {
return timestamps;
}
const labels = me.getLabels();
for (i = 0, ilen = labels.length; i < ilen; ++i) {
timestamps.push(me.parse(labels[i]));
}
// We could assume labels are in order and unique - but let's not
return (me._cache.labels = timestamps);
return interpolate(this._table, value) / this._maxIndex;
}
/**
@ -184,8 +147,8 @@ class TimeSeriesScale extends TimeScale {
getValueForPixel(pixel) {
const me = this;
const offsets = me._offsets;
const pos = me.getDecimalForPixel(pixel) / offsets.factor - offsets.end;
return interpolate(me._table, 'pos', pos, 'time');
const decimal = me.getDecimalForPixel(pixel) / offsets.factor - offsets.end;
return interpolate(me._table, decimal * this._maxIndex, true);
}
}

View File

@ -712,144 +712,147 @@ describe('Time scale tests', function() {
});
});
describe('when scale type', function() {
describe('is "timeseries"', function() {
beforeEach(function() {
this.chart = window.acquireChart({
type: 'line',
data: {
labels: ['2017', '2019', '2020', '2025', '2042'],
datasets: [{data: [0, 1, 2, 3, 4, 5]}]
},
options: {
scales: {
x: {
type: 'timeseries',
time: {
parser: 'YYYY'
[true, false].forEach(function(normalized) {
describe('when normalized is ' + normalized + ' and scale type', function() {
describe('is "timeseries"', function() {
beforeEach(function() {
this.chart = window.acquireChart({
type: 'line',
data: {
labels: ['2017', '2019', '2020', '2025', '2042'],
datasets: [{data: [0, 1, 2, 3, 4]}]
},
options: {
normalized,
scales: {
x: {
type: 'timeseries',
time: {
parser: 'YYYY'
},
ticks: {
source: 'labels'
}
},
ticks: {
source: 'labels'
y: {
display: false
}
},
y: {
display: false
}
}
}
});
});
it ('should space data out with the same gap, whatever their time values', function() {
var scale = this.chart.scales.x;
var start = scale.left;
var slice = scale.width / 4;
expect(scale.getPixelForValue(moment('2017').valueOf(), 0)).toBeCloseToPixel(start);
expect(scale.getPixelForValue(moment('2019').valueOf(), 1)).toBeCloseToPixel(start + slice);
expect(scale.getPixelForValue(moment('2020').valueOf(), 2)).toBeCloseToPixel(start + slice * 2);
expect(scale.getPixelForValue(moment('2025').valueOf(), 3)).toBeCloseToPixel(start + slice * 3);
expect(scale.getPixelForValue(moment('2042').valueOf(), 4)).toBeCloseToPixel(start + slice * 4);
});
it ('should add a step before if scale.min is before the first data', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.x;
options.min = '2012';
chart.update();
var start = scale.left;
var slice = scale.width / 5;
expect(scale.getPixelForValue(moment('2017').valueOf(), 1)).toBeCloseToPixel(start + slice);
expect(scale.getPixelForValue(moment('2042').valueOf(), 5)).toBeCloseToPixel(start + slice * 5);
});
it ('should add a step after if scale.max is after the last data', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.x;
options.max = '2050';
chart.update();
var start = scale.left;
var slice = scale.width / 5;
expect(scale.getPixelForValue(moment('2017').valueOf(), 0)).toBeCloseToPixel(start);
expect(scale.getPixelForValue(moment('2042').valueOf(), 4)).toBeCloseToPixel(start + slice * 4);
});
it ('should add steps before and after if scale.min/max are outside the data range', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.x;
options.min = '2012';
options.max = '2050';
chart.update();
var start = scale.left;
var slice = scale.width / 6;
expect(scale.getPixelForValue(moment('2017').valueOf(), 1)).toBeCloseToPixel(start + slice);
expect(scale.getPixelForValue(moment('2042').valueOf(), 5)).toBeCloseToPixel(start + slice * 5);
});
});
it ('should space data out with the same gap, whatever their time values', function() {
var scale = this.chart.scales.x;
var start = scale.left;
var slice = scale.width / 4;
expect(scale.getPixelForValue(moment('2017').valueOf())).toBeCloseToPixel(start);
expect(scale.getPixelForValue(moment('2019').valueOf())).toBeCloseToPixel(start + slice);
expect(scale.getPixelForValue(moment('2020').valueOf())).toBeCloseToPixel(start + slice * 2);
expect(scale.getPixelForValue(moment('2025').valueOf())).toBeCloseToPixel(start + slice * 3);
expect(scale.getPixelForValue(moment('2042').valueOf())).toBeCloseToPixel(start + slice * 4);
});
it ('should add a step before if scale.min is before the first data', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.x;
options.min = '2012';
chart.update();
var start = scale.left;
var slice = scale.width / 5;
expect(scale.getPixelForValue(moment('2017').valueOf())).toBeCloseToPixel(start + slice);
expect(scale.getPixelForValue(moment('2042').valueOf())).toBeCloseToPixel(start + slice * 5);
});
it ('should add a step after if scale.max is after the last data', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.x;
options.max = '2050';
chart.update();
var start = scale.left;
var slice = scale.width / 5;
expect(scale.getPixelForValue(moment('2017').valueOf())).toBeCloseToPixel(start);
expect(scale.getPixelForValue(moment('2042').valueOf())).toBeCloseToPixel(start + slice * 4);
});
it ('should add steps before and after if scale.min/max are outside the data range', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.x;
options.min = '2012';
options.max = '2050';
chart.update();
var start = scale.left;
var slice = scale.width / 6;
expect(scale.getPixelForValue(moment('2017').valueOf())).toBeCloseToPixel(start + slice);
expect(scale.getPixelForValue(moment('2042').valueOf())).toBeCloseToPixel(start + slice * 5);
});
});
describe('is "time"', function() {
beforeEach(function() {
this.chart = window.acquireChart({
type: 'line',
data: {
labels: ['2017', '2019', '2020', '2025', '2042'],
datasets: [{data: [0, 1, 2, 3, 4, 5]}]
},
options: {
scales: {
x: {
type: 'time',
time: {
parser: 'YYYY'
describe('is "time"', function() {
beforeEach(function() {
this.chart = window.acquireChart({
type: 'line',
data: {
labels: ['2017', '2019', '2020', '2025', '2042'],
datasets: [{data: [0, 1, 2, 3, 4, 5]}]
},
options: {
scales: {
x: {
type: 'time',
time: {
parser: 'YYYY'
},
ticks: {
source: 'labels'
}
},
ticks: {
source: 'labels'
y: {
display: false
}
},
y: {
display: false
}
}
}
});
});
});
it ('should space data out with a gap relative to their time values', function() {
var scale = this.chart.scales.x;
var start = scale.left;
var slice = scale.width / (2042 - 2017);
it ('should space data out with a gap relative to their time values', function() {
var scale = this.chart.scales.x;
var start = scale.left;
var slice = scale.width / (2042 - 2017);
expect(scale.getPixelForValue(moment('2017').valueOf())).toBeCloseToPixel(start);
expect(scale.getPixelForValue(moment('2019').valueOf())).toBeCloseToPixel(start + slice * (2019 - 2017));
expect(scale.getPixelForValue(moment('2020').valueOf())).toBeCloseToPixel(start + slice * (2020 - 2017));
expect(scale.getPixelForValue(moment('2025').valueOf())).toBeCloseToPixel(start + slice * (2025 - 2017));
expect(scale.getPixelForValue(moment('2042').valueOf())).toBeCloseToPixel(start + slice * (2042 - 2017));
});
it ('should take in account scale min and max if outside the ticks range', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.x;
expect(scale.getPixelForValue(moment('2017').valueOf(), 0)).toBeCloseToPixel(start);
expect(scale.getPixelForValue(moment('2019').valueOf(), 1)).toBeCloseToPixel(start + slice * (2019 - 2017));
expect(scale.getPixelForValue(moment('2020').valueOf(), 2)).toBeCloseToPixel(start + slice * (2020 - 2017));
expect(scale.getPixelForValue(moment('2025').valueOf(), 3)).toBeCloseToPixel(start + slice * (2025 - 2017));
expect(scale.getPixelForValue(moment('2042').valueOf(), 4)).toBeCloseToPixel(start + slice * (2042 - 2017));
});
it ('should take in account scale min and max if outside the ticks range', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.x;
options.min = '2012';
options.max = '2050';
chart.update();
options.min = '2012';
options.max = '2050';
chart.update();
var start = scale.left;
var slice = scale.width / (2050 - 2012);
var start = scale.left;
var slice = scale.width / (2050 - 2012);
expect(scale.getPixelForValue(moment('2017').valueOf())).toBeCloseToPixel(start + slice * (2017 - 2012));
expect(scale.getPixelForValue(moment('2019').valueOf())).toBeCloseToPixel(start + slice * (2019 - 2012));
expect(scale.getPixelForValue(moment('2020').valueOf())).toBeCloseToPixel(start + slice * (2020 - 2012));
expect(scale.getPixelForValue(moment('2025').valueOf())).toBeCloseToPixel(start + slice * (2025 - 2012));
expect(scale.getPixelForValue(moment('2042').valueOf())).toBeCloseToPixel(start + slice * (2042 - 2012));
expect(scale.getPixelForValue(moment('2017').valueOf(), 0)).toBeCloseToPixel(start + slice * (2017 - 2012));
expect(scale.getPixelForValue(moment('2019').valueOf(), 1)).toBeCloseToPixel(start + slice * (2019 - 2012));
expect(scale.getPixelForValue(moment('2020').valueOf(), 2)).toBeCloseToPixel(start + slice * (2020 - 2012));
expect(scale.getPixelForValue(moment('2025').valueOf(), 3)).toBeCloseToPixel(start + slice * (2025 - 2012));
expect(scale.getPixelForValue(moment('2042').valueOf(), 4)).toBeCloseToPixel(start + slice * (2042 - 2012));
});
});
});
});