From fa09223d89f571680c84cc68b4260a01cf5df17f Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Tue, 3 Sep 2019 15:42:51 -0700 Subject: [PATCH] Add ticks.sampleSize option --- docs/axes/cartesian/README.md | 1 + docs/general/performance.md | 8 +++ src/core/core.scale.js | 132 +++++++++++++++++++++------------- 3 files changed, 93 insertions(+), 48 deletions(-) create mode 100644 docs/general/performance.md diff --git a/docs/axes/cartesian/README.md b/docs/axes/cartesian/README.md index c9e126cc07d..4a97dfb16b6 100644 --- a/docs/axes/cartesian/README.md +++ b/docs/axes/cartesian/README.md @@ -28,6 +28,7 @@ The following options are common to all cartesian axes but do not apply to other | ---- | ---- | ------- | ----------- | `min` | `number` | | User defined minimum value for the scale, overrides minimum value from data. | `max` | `number` | | User defined maximum value for the scale, overrides maximum value from data. +| `sampleSize` | `number` | `ticks.length` | The number of ticks to examine when deciding how many labels will fit. Setting a smaller value will be faster, but may be less accurate when there is large variability in label length. | `autoSkip` | `boolean` | `true` | If true, automatically calculates how many labels can be shown and hides labels accordingly. Labels will be rotated up to `maxRotation` before skipping any. Turn `autoSkip` off to show all labels no matter what. | `autoSkipPadding` | `number` | `0` | Padding between the ticks on the horizontal axis when `autoSkip` is enabled. | `labelOffset` | `number` | `0` | Distance in pixels to offset the label from the centre point of the tick (in the x direction for the x axis, and the y direction for the y axis). *Note: this can cause labels at the edges to be cropped by the edge of the canvas* diff --git a/docs/general/performance.md b/docs/general/performance.md new file mode 100644 index 00000000000..2d3397e6eea --- /dev/null +++ b/docs/general/performance.md @@ -0,0 +1,8 @@ +# 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: + +* Set `animation: { duration: 0 }` to disable [animations](../configuration/animations.md). +* For large datasets: + * You may wish to sample your data before providing it to Chart.js. E.g. if you have a data point for each day, you may find it more performant to pass in a data point for each week instead + * Set the [`ticks.sampleSize`](../axes/cartesian/README.md#tick-configuration) option in order to render axes more quickly diff --git a/src/core/core.scale.js b/src/core/core.scale.js index 8f532ca754f..26c078f3e79 100644 --- a/src/core/core.scale.js +++ b/src/core/core.scale.js @@ -67,6 +67,25 @@ defaults._set('scale', { } }); +function sample(arr, size) { + var shuffled = arr.slice(0); + var i = arr.length; + var min = i - size; + var tmp, index; + + if (size >= min) { + return arr; + } + + while (i-- > min) { + index = Math.floor((i + 1) * Math.random()); + tmp = shuffled[index]; + shuffled[index] = shuffled[i]; + shuffled[i] = tmp; + } + return shuffled.slice(min); +} + function getPixelForGridLine(scale, index, offsetGridLines) { var length = scale.getTicks().length; var validIndex = Math.min(index, length - 1); @@ -263,7 +282,7 @@ var Scale = Element.extend({ update: function(maxWidth, maxHeight, margins) { var me = this; var tickOpts = me.options.ticks; - var i, ilen, labels, label, ticks, tick; + var i, ilen, labels, ticks; // Update Lifecycle - Probably don't want to ever extend or overwrite this function ;) me.beforeUpdate(); @@ -306,39 +325,28 @@ var Scale = Element.extend({ // New implementations should return an array of objects but for BACKWARD COMPAT, // we still support no return (`this.ticks` internally set by calling this method). - ticks = me.buildTicks() || []; + ticks = me.buildTicks(); // Allow modification of ticks in callback. - ticks = me.afterBuildTicks(ticks) || ticks; - - me.beforeTickToLabelConversion(); - - // New implementations should return the formatted tick labels but for BACKWARD - // COMPAT, we still support no return (`this.ticks` internally changed by calling - // this method and supposed to contain only string values). - labels = me.convertTicksToLabels(ticks) || me.ticks; - - me.afterTickToLabelConversion(); - - me.ticks = labels; // BACKWARD COMPATIBILITY - - // IMPORTANT: below this point, we consider that `this.ticks` will NEVER change! - - // BACKWARD COMPAT: synchronize `_ticks` with labels (so potentially `this.ticks`) - for (i = 0, ilen = labels.length; i < ilen; ++i) { - label = labels[i]; - tick = ticks[i]; - if (!tick) { - ticks.push(tick = { - label: label, + if (ticks) { + ticks = me.afterBuildTicks(ticks); + } else { + // Support old implementations (that modified `this.ticks` directly in buildTicks) + me.ticks = me.afterBuildTicks(me.ticks) || []; + ticks = []; + for (i = 0, ilen = me.ticks.length; i < ilen; ++i) { + ticks.push({ + value: me.ticks[i], major: false }); - } else { - tick.label = label; } } - me._ticks = ticks; + // Compute tick rotation and fit using a sampled subset of labels + // We generally don't need to compute the size of every single label for determining scale size + me._ticks = sample(ticks, tickOpts.sampleSize || ticks.length); + + labels = me._convertTicksToLabels(me._ticks); // _configure is called twice, once here, once from core.controller.updateLayout. // Here we haven't been positioned yet, but dimensions are correct. @@ -350,19 +358,29 @@ var Scale = Element.extend({ me.beforeCalculateTickRotation(); me.calculateTickRotation(); me.afterCalculateTickRotation(); - // Fit + me.beforeFit(); me.fit(); me.afterFit(); + // Auto-skip - me._ticksToDraw = tickOpts.display && tickOpts.autoSkip ? me._autoSkip(me._ticks) : me._ticks; + me._ticks = ticks; + me._ticksToDraw = tickOpts.display && tickOpts.autoSkip ? me._autoSkip(ticks) : ticks; + + if (tickOpts.sampleSize) { + // Generate labels using all non-skipped ticks + labels = me._convertTicksToLabels(me._ticksToDraw); + } + + me.ticks = labels; // BACKWARD COMPATIBILITY + + // IMPORTANT: after this point, we consider that `this.ticks` will NEVER change! me.afterUpdate(); // TODO(v3): remove minSize as a public property and return value from all layout boxes. It is unused // make maxWidth and maxHeight private return me.minSize; - }, /** @@ -439,13 +457,7 @@ var Scale = Element.extend({ buildTicks: helpers.noop, afterBuildTicks: function(ticks) { var me = this; - // ticks is empty for old axis implementations here - if (isArray(ticks) && ticks.length) { - return helpers.callback(me.options.afterBuildTicks, [me, ticks]); - } - // Support old implementations (that modified `this.ticks` directly in buildTicks) - me.ticks = helpers.callback(me.options.afterBuildTicks, [me, me.ticks]) || me.ticks; - return ticks; + return helpers.callback(me.options.afterBuildTicks, [me, ticks]) || ticks; }, beforeTickToLabelConversion: function() { @@ -623,12 +635,10 @@ var Scale = Element.extend({ */ handleMargins: function() { var me = this; - if (me.margins) { - me.margins.left = Math.max(me.paddingLeft, me.margins.left); - me.margins.top = Math.max(me.paddingTop, me.margins.top); - me.margins.right = Math.max(me.paddingRight, me.margins.right); - me.margins.bottom = Math.max(me.paddingBottom, me.margins.bottom); - } + me.margins.left = Math.max(me.paddingLeft, me.margins.left); + me.margins.top = Math.max(me.paddingTop, me.margins.top); + me.margins.right = Math.max(me.paddingRight, me.margins.right); + me.margins.bottom = Math.max(me.paddingBottom, me.margins.bottom); }, afterFit: function() { @@ -670,6 +680,31 @@ var Scale = Element.extend({ return rawValue; }, + _convertTicksToLabels: function(ticks) { + var me = this; + var labels, i, ilen; + + me.ticks = ticks.map(function(tick) { + return tick.value; + }); + + me.beforeTickToLabelConversion(); + + // New implementations should return the formatted tick labels but for BACKWARD + // COMPAT, we still support no return (`this.ticks` internally changed by calling + // this method and supposed to contain only string values). + labels = me.convertTicksToLabels(ticks) || me.ticks; + + me.afterTickToLabelConversion(); + + // BACKWARD COMPAT: synchronize `_ticks` with labels (so potentially `this.ticks`) + for (i = 0, ilen = ticks.length; i < ilen; ++i) { + ticks[i].label = labels[i]; + } + + return labels; + }, + /** * @private */ @@ -832,11 +867,12 @@ var Scale = Element.extend({ for (i = 0; i < tickCount; i++) { tick = ticks[i]; - if (skipRatio > 1 && i % skipRatio > 0) { - // leave tick in place but make sure it's not displayed (#4635) + if (skipRatio <= 1 || i % skipRatio === 0) { + tick._index = i; + result.push(tick); + } else { delete tick.label; } - result.push(tick); } return result; }, @@ -963,7 +999,7 @@ var Scale = Element.extend({ borderDashOffset = gridLines.borderDashOffset || 0.0; } - lineValue = getPixelForGridLine(me, i, offsetGridLines); + lineValue = getPixelForGridLine(me, tick._index || i, offsetGridLines); // Skip if the pixel is out of the range if (lineValue === undefined) { @@ -1041,7 +1077,7 @@ var Scale = Element.extend({ continue; } - pixel = me.getPixelForTick(i) + optionTicks.labelOffset; + pixel = me.getPixelForTick(tick._index || i) + optionTicks.labelOffset; font = tick.major ? fonts.major : fonts.minor; lineHeight = font.lineHeight; lineCount = isArray(label) ? label.length : 1;