Skip to content

Commit 650956b

Browse files
authored
Create a new hook to enable data decimation (#8255)
* Create a new hook to enable data decimation The `beforeElementUpdate` hook can be used to decimate data. The chart elements will not be created until after this hook has fired ensuring that if decimation occurs, only the needed elements will be created. * Address code review feedback * Rename hook to beforeElementsUpdate * Simplify parsing logic * Add decimation plugin to the core * Allow a dataset to specify a different data key * Decimation plugin uses the dataKey feature * Refactor the decimation plugin to support configurable algorithms * Lint the plugin changes * Tests for the dataKey feature * Convert test files to tabs * Standardize on tabs in ts files * Remove the dataKey feature * Replace dataKey usage in decimation plugin We define a new descriptor for the `data` key allowing the plugin to be simpler. * Disable decimation when indexAxis is Y * Simplify the decimation width approximation * Resolve the indexAxis correctly in all cases * Initial documentation * Reverse check * Update TS definitions for new plugin options * Move defineProperty after bailouts * Add destroy hook
1 parent df4cabd commit 650956b

File tree

8 files changed

+213
-25
lines changed

8 files changed

+213
-25
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
title: Data Decimation
3+
---
4+
5+
The decimation plugin can be used with line charts to automatically decimate data at the start of the chart lifecycle. Before enabling this plugin, review the [requirements](#requirements) to ensure that it will work with the chart you want to create.
6+
7+
## Configuration Options
8+
9+
The decimation plugin configuration is passed into the `options.plugins.decimation` namespace. The global options for the plugin are defined in `Chart.defaults.plugins.decimation`.
10+
11+
| Name | Type | Default | Description
12+
| ---- | ---- | ------- | -----------
13+
| `enabled` | `boolean` | `true` | Is decimation enabled?
14+
| `algorithm` | `string` | `'min-max'` | Decimation algorithm to use. See the [more...](#decimation-algorithms)
15+
16+
## Decimation Algorithms
17+
18+
Decimation algorithm to use for data. Options are:
19+
20+
* `'min-max'`
21+
22+
### Min/Max Decimation
23+
24+
[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.
25+
26+
## Requirements
27+
28+
To use the decimation plugin, the following requirements must be met:
29+
30+
1. The dataset must have an `indexAxis` of `'x'`
31+
2. The dataset must be a line
32+
3. The X axis for the dataset must be either a `'linear'` or `'time'` type axis
33+
4. The dataset object must be mutable. The plugin stores the original data as `dataset._data` and then defines a new `data` property on the dataset.

docs/docs/general/performance.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Chart.js is fastest if you provide data with indices that are unique, sorted, an
1818

1919
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.
2020

21-
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.
21+
The [decimation plugin](./configuration/decimation.md) can be used with line charts to decimate data before the chart is rendered. This will provide the best performance since it will reduce the memory needed to render the chart.
2222

2323
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.
2424

docs/sidebars.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ module.exports = {
3030
'configuration/legend',
3131
'configuration/title',
3232
'configuration/tooltip',
33-
'configuration/elements'
33+
'configuration/elements',
34+
'configuration/decimation'
3435
],
3536
'Chart Types': [
3637
'charts/line',

src/core/core.controller.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,9 +466,15 @@ class Chart {
466466
// Make sure dataset controllers are updated and new controllers are reset
467467
const newControllers = me.buildOrUpdateControllers();
468468

469+
me.notifyPlugins('beforeElementsUpdate');
470+
469471
// Make sure all dataset controllers have correct meta data counts
470472
for (i = 0, ilen = me.data.datasets.length; i < ilen; i++) {
471-
me.getDatasetMeta(i).controller.buildOrUpdateElements();
473+
const {controller} = me.getDatasetMeta(i);
474+
const reset = !animsDisabled && newControllers.indexOf(controller) === -1;
475+
// New controllers will be reset after the layout pass, so we only want to modify
476+
// elements added to new datasets
477+
controller.buildOrUpdateElements(reset);
472478
}
473479

474480
me._updateLayout();

src/core/core.datasetController.js

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -349,19 +349,12 @@ export default class DatasetController {
349349

350350
me._dataCheck();
351351

352-
const data = me._data;
353-
const metaData = meta.data = new Array(data.length);
354-
355-
for (let i = 0, ilen = data.length; i < ilen; ++i) {
356-
metaData[i] = new me.dataElementType();
357-
}
358-
359352
if (me.datasetElementType) {
360353
meta.dataset = new me.datasetElementType();
361354
}
362355
}
363356

364-
buildOrUpdateElements() {
357+
buildOrUpdateElements(resetNewElements) {
365358
const me = this;
366359
const meta = me._cachedMeta;
367360
const dataset = me.getDataset();
@@ -382,7 +375,7 @@ export default class DatasetController {
382375

383376
// Re-sync meta data in case the user replaced the data array or if we missed
384377
// any updates and so make sure that we handle number of datapoints changing.
385-
me._resyncElements();
378+
me._resyncElements(resetNewElements);
386379

387380
// if stack changed, update stack values for the whole dataset
388381
if (stackChanged) {
@@ -402,7 +395,10 @@ export default class DatasetController {
402395
me.getDataset(),
403396
], {
404397
merger(key, target, source) {
405-
if (key !== 'data') {
398+
// Cloning the data is expensive and unnecessary.
399+
// Additionally, plugins may add dataset level fields that should
400+
// not be cloned. We identify those via an underscore prefix
401+
if (key !== 'data' && key.charAt(0) !== '_') {
406402
_merger(key, target, source);
407403
}
408404
}
@@ -419,13 +415,10 @@ export default class DatasetController {
419415
const {_cachedMeta: meta, _data: data} = me;
420416
const {iScale, _stacked} = meta;
421417
const iAxis = iScale.axis;
422-
let sorted = true;
423-
let i, parsed, cur, prev;
424418

425-
if (start > 0) {
426-
sorted = meta._sorted;
427-
prev = meta._parsed[start - 1];
428-
}
419+
let sorted = start === 0 && count === data.length ? true : meta._sorted;
420+
let prev = start > 0 && meta._parsed[start - 1];
421+
let i, cur, parsed;
429422

430423
if (me._parsing === false) {
431424
meta._parsed = data;
@@ -971,13 +964,13 @@ export default class DatasetController {
971964
/**
972965
* @private
973966
*/
974-
_resyncElements() {
967+
_resyncElements(resetNewElements) {
975968
const me = this;
976969
const numMeta = me._cachedMeta.data.length;
977970
const numData = me._data.length;
978971

979972
if (numData > numMeta) {
980-
me._insertElements(numMeta, numData - numMeta);
973+
me._insertElements(numMeta, numData - numMeta, resetNewElements);
981974
} else if (numData < numMeta) {
982975
me._removeElements(numData, numMeta - numData);
983976
}
@@ -988,7 +981,7 @@ export default class DatasetController {
988981
/**
989982
* @private
990983
*/
991-
_insertElements(start, count) {
984+
_insertElements(start, count, resetNewElements = true) {
992985
const me = this;
993986
const elements = new Array(count);
994987
const meta = me._cachedMeta;
@@ -1005,7 +998,9 @@ export default class DatasetController {
1005998
}
1006999
me.parse(start, count);
10071000

1008-
me.updateElements(data, start, count, 'reset');
1001+
if (resetNewElements) {
1002+
me.updateElements(data, start, count, 'reset');
1003+
}
10091004
}
10101005

10111006
updateElements(element, start, count, mode) {} // eslint-disable-line no-unused-vars

src/plugins/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export {default as Decimation} from './plugin.decimation';
12
export {default as Filler} from './plugin.filler';
23
export {default as Legend} from './plugin.legend';
34
export {default as Title} from './plugin.title';

src/plugins/plugin.decimation.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {isNullOrUndef, resolve} from '../helpers';
2+
3+
function minMaxDecimation(data, availableWidth) {
4+
let i, point, x, y, prevX, minIndex, maxIndex, minY, maxY;
5+
const decimated = [];
6+
7+
const xMin = data[0].x;
8+
const xMax = data[data.length - 1].x;
9+
const dx = xMax - xMin;
10+
11+
for (i = 0; i < data.length; ++i) {
12+
point = data[i];
13+
x = (point.x - xMin) / dx * availableWidth;
14+
y = point.y;
15+
const truncX = x | 0;
16+
17+
if (truncX === prevX) {
18+
// Determine `minY` / `maxY` and `avgX` while we stay within same x-position
19+
if (y < minY) {
20+
minY = y;
21+
minIndex = i;
22+
} else if (y > maxY) {
23+
maxY = y;
24+
maxIndex = i;
25+
}
26+
} else {
27+
// Push up to 4 points, 3 for the last interval and the first point for this interval
28+
if (minIndex && maxIndex) {
29+
decimated.push(data[minIndex], data[maxIndex]);
30+
}
31+
if (i > 0) {
32+
// Last point in the previous interval
33+
decimated.push(data[i - 1]);
34+
}
35+
decimated.push(point);
36+
prevX = truncX;
37+
minY = maxY = y;
38+
minIndex = maxIndex = i;
39+
}
40+
}
41+
42+
return decimated;
43+
}
44+
45+
export default {
46+
id: 'decimation',
47+
48+
defaults: {
49+
algorithm: 'min-max',
50+
enabled: false,
51+
},
52+
53+
beforeElementsUpdate: (chart, args, options) => {
54+
if (!options.enabled) {
55+
return;
56+
}
57+
58+
// Assume the entire chart is available to show a few more points than needed
59+
const availableWidth = chart.width;
60+
61+
chart.data.datasets.forEach((dataset, datasetIndex) => {
62+
const {_data, indexAxis} = dataset;
63+
const meta = chart.getDatasetMeta(datasetIndex);
64+
const data = _data || dataset.data;
65+
66+
if (resolve([indexAxis, chart.options.indexAxis]) === 'y') {
67+
// Decimation is only supported for lines that have an X indexAxis
68+
return;
69+
}
70+
71+
if (meta.type !== 'line') {
72+
// Only line datasets are supported
73+
return;
74+
}
75+
76+
const xAxis = chart.scales[meta.xAxisID];
77+
if (xAxis.type !== 'linear' && xAxis.type !== 'time') {
78+
// Only linear interpolation is supported
79+
return;
80+
}
81+
82+
if (chart.options.parsing) {
83+
// Plugin only supports data that does not need parsing
84+
return;
85+
}
86+
87+
if (data.length <= 4 * availableWidth) {
88+
// No decimation is required until we are above this threshold
89+
return;
90+
}
91+
92+
if (isNullOrUndef(_data)) {
93+
// First time we are seeing this dataset
94+
// We override the 'data' property with a setter that stores the
95+
// raw data in _data, but reads the decimated data from _decimated
96+
// TODO: Undo this on chart destruction
97+
dataset._data = data;
98+
delete dataset.data;
99+
Object.defineProperty(dataset, 'data', {
100+
configurable: true,
101+
enumerable: true,
102+
get: function() {
103+
return this._decimated;
104+
},
105+
set: function(d) {
106+
this._data = d;
107+
}
108+
});
109+
}
110+
111+
// Point the chart to the decimated data
112+
let decimated;
113+
switch (options.algorithm) {
114+
case 'min-max':
115+
decimated = minMaxDecimation(data, availableWidth);
116+
break;
117+
default:
118+
throw new Error(`Unsupported decimation algorithm '${options.algorithm}'`);
119+
}
120+
121+
dataset._decimated = decimated;
122+
});
123+
},
124+
125+
destroy(chart) {
126+
chart.data.datasets.forEach((dataset) => {
127+
if (dataset._decimated) {
128+
const data = dataset._data;
129+
delete dataset._decimated;
130+
delete dataset._data;
131+
Object.defineProperty(dataset, 'data', {value: data});
132+
}
133+
});
134+
}
135+
};

types/index.esm.d.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ export class DatasetController<TElement extends Element = Element, TDatasetEleme
539539
configure(): void;
540540
initialize(): void;
541541
addElements(): void;
542-
buildOrUpdateElements(): void;
542+
buildOrUpdateElements(resetNewElements?: boolean): void;
543543

544544
getStyle(index: number, active: boolean): any;
545545
protected resolveDatasetElementOptions(active: boolean): any;
@@ -789,6 +789,14 @@ export interface Plugin<O = {}> extends ExtendedPlugin {
789789
* @param {object} options - The plugin options.
790790
*/
791791
afterUpdate?(chart: Chart, args: { mode: UpdateMode }, options: O): void;
792+
/**
793+
* @desc Called during the update process, before any chart elements have been created.
794+
* This can be used for data decimation by changing the data array inside a dataset.
795+
* @param {Chart} chart - The chart instance.
796+
* @param {object} args - The call arguments.
797+
* @param {object} options - The plugin options.
798+
*/
799+
beforeElementsUpdate?(chart: Chart, args: {}, options: O): void;
792800
/**
793801
* @desc Called during chart reset
794802
* @param {Chart} chart - The chart instance.
@@ -1902,8 +1910,16 @@ export class BasePlatform {
19021910
export class BasicPlatform extends BasePlatform {}
19031911
export class DomPlatform extends BasePlatform {}
19041912

1905-
export const Filler: Plugin;
1913+
export declare enum DecimationAlgorithm {
1914+
minmax = 'min-max',
1915+
}
19061916

1917+
export interface DecimationOptions {
1918+
enabled: boolean;
1919+
algorithm: DecimationAlgorithm;
1920+
}
1921+
1922+
export const Filler: Plugin;
19071923
export interface FillerOptions {
19081924
propagate: boolean;
19091925
}
@@ -2477,6 +2493,7 @@ export interface TooltipItem {
24772493
}
24782494

24792495
export interface PluginOptionsByType {
2496+
decimation: DecimationOptions;
24802497
filler: FillerOptions;
24812498
legend: LegendOptions;
24822499
title: TitleOptions;

0 commit comments

Comments
 (0)