From: Jukka Kurkela Date: Sat, 20 Feb 2021 14:02:22 +0000 (+0200) Subject: Isolate properties / modes from animation options (#8332) X-Git-Tag: v3.0.0-beta.11~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=5d5e48d01b0aed1279c60c5ea0c97d5328de346e;p=thirdparty%2FChart.js.git Isolate properties / modes from animation options (#8332) * Isolate properties / modes from animation options * tabs, something wrong with the linter * Update misleading variable name --- diff --git a/docs/docs/configuration/animations.mdx b/docs/docs/configuration/animations.mdx index c1ed00497..f3ca5e30b 100644 --- a/docs/docs/configuration/animations.mdx +++ b/docs/docs/configuration/animations.mdx @@ -33,21 +33,21 @@ function example() { }] }, options: { - animation: { - tension: { - duration: 1000, - easing: 'linear', - from: 1, - to: 0, - loop: true - } - }, - scales: { - y: { // defining min and max so hiding the dataset does not change scale range - min: 0, - max: 100 - } + animations: { + tension: { + duration: 1000, + easing: 'linear', + from: 1, + to: 0, + loop: true + } + }, + scales: { + y: { // defining min and max so hiding the dataset does not change scale range + min: 0, + max: 100 } + } } }; const chart = new Chart(ctx, cfg); @@ -77,24 +77,24 @@ function example() { }] }, options: { - animation: { - show: { - x: { - from: 0 - }, - y: { - from: 0 - } + transitions: { + show: { + x: { + from: 0 }, - hide: { - x: { - to: 0 - }, - y: { - to: 0 - } + y: { + from: 0 + } + }, + hide: { + x: { + to: 0 + }, + y: { + to: 0 } } + } } }; const chart = new Chart(ctx, cfg); @@ -107,10 +107,30 @@ function example() { -## Animation Configuration +## Animation configuration + +Animation configuration consists of 3 keys. + +| Name | Type | Details +| ---- | ---- | ------- +| animation | `object` | [animation](#animation) +| animations | `object` | [animations](#animations) +| transitions | `object` | [transitions](#transitions) + +These keys can be configured in following paths: -The default configuration is defined here: core.animations.js -Namespace: `options.animation`, the global options are defined in `Chart.defaults.animation`. +* `` - chart options +* `controllers[type]` - controller type options +* `controllers[type].datasets` - dataset type options +* `datasets[type]` - dataset type options + +These paths are valid under `defaults` for global confuguration and `options` for instance configuration. + +## animation + +The default configuration is defined here: core.animations.js + +Namespace: `options.animation` | Name | Type | Default | Description | ---- | ---- | ------- | ----------- @@ -119,84 +139,65 @@ Namespace: `options.animation`, the global options are defined in `Chart.defaul | `debug` | `boolean` | `undefined` | Running animation count + FPS display in upper left corner of the chart. | `delay` | `number` | `undefined` | Delay before starting the animations. | `loop` | `boolean` | `undefined` | If set to `true`, the animations loop endlessly. -| [[mode]](#animation-mode-configuration) | `object` | [defaults...](#default-modes) | Option overrides for update mode. Core modes: `'active'`, `'hide'`, `'reset'`, `'resize'`, `'show'`. See **Hide and show [mode]** example above. -| [[property]](#animation-property-configuration) | `object` | `undefined` | Option overrides for a single element `[property]`. These have precedence over `[collection]`. See **Looping tension [property]** example above. -| [[collection]](#animation-properties-collection-configuration) | `object` | [defaults...](#default-collections) | Option overrides for multiple properties, identified by `properties` array. These defaults can be overridden in `options.animation` or `dataset.animation` and `tooltip.animation`. These keys are also [Scriptable](../general/options.md#scriptable-options). -## Animation mode configuration +## animations -Mode option configures how an update mode animates the chart. -The cores modes are `'active'`, `'hide'`, `'reset'`, `'resize'`, `'show'`. -A custom mode can be used by passing a custom `mode` to [update](../developers/api.md#updatemode). -A mode option is defined by the same options of the main [animation configuration](#animation-configuration). +Animations options configures which element properties are animated and how. +In addition to the main [animation configuration](#animation-configuration), the following options are available: -### Default modes - -Namespace: `options.animation` - -| Mode | Option | Value | Description -| -----| ------ | ----- | ----- -| `active` | duration | 400 | Override default duration to 400ms for hover animations -| `resize` | duration | 0 | Override default duration to 0ms (= no animation) for resize -| `show` | colors | `{ type: 'color', properties: ['borderColor', 'backgroundColor'], from: 'transparent' }` | Colors are faded in from transparent when dataset is shown using legend / [api](../developers/api.md#showdatasetIndex). -| `show` | visible | `{ type: 'boolean', duration: 0 }` | Dataset visiblity is immediately changed to true so the color transition from transparent is visible. -| `hide` | colors | `{ type: 'color', properties: ['borderColor', 'backgroundColor'], to: 'transparent' }` | Colors are faded to transparent when dataset id hidden using legend / [api](../developers/api.md#hidedatasetIndex). -| `hide` | visible | `{ type: 'boolean', easing: 'easeInExpo' }` | Visibility is changed to false at a very late phase of animation - -## Animation property configuration - -Property option configures which element property to use to animate the chart and its starting and ending values. -A property option is defined by the same options of the main [animation configuration](#animation-configuration), adding the following ones: - -Namespace: `options.animation[animation]` +Namespace: `options.animations[animation]` | Name | Type | Default | Description | ---- | ---- | ------- | ----------- +| `properties` | `string[]` | `key` | The property names this configuration applies to. Defaults to the key name of this object. | `type` | `string` | `typeof property` | Type of property, determines the interpolator used. Possible values: `'number'`, `'color'` and `'boolean'`. Only really needed for `'color'`, because `typeof` does not get that right. | `from` | `number`\|`Color`\|`boolean` | `undefined` | Start value for the animation. Current value is used when `undefined` | `to` | `number`\|`Color`\|`boolean` | `undefined` | End value for the animation. Updated value is used when `undefined` | `fn` | <T>(from: T, to: T, factor: number) => T; | `undefined` | Optional custom interpolator, instead of using a predefined interpolator from `type` | -## Animation properties collection configuration - -Properties collection option configures which set of element properties to use to animate the chart. -Collection can be named whatever you like, but should not collide with a `[property]` or `[mode]`. -A properties collection option is defined by the same options as the [animation property configuration](#animation-property-configuration), adding the following one: - -The animation properties collection configuration can be adjusted in the `options.animation[collection]` namespace. - -| Name | Type | Default | Description -| ---- | ---- | ------- | ----------- -| `properties` | `string[]` | `undefined` | Set of properties to use to animate the chart. - -### Default collections +### Default animations | Name | Option | Value | ---- | ------ | ----- -| `numbers` | `type` | `'number'` | `numbers` | `properties` | `['x', 'y', 'borderWidth', 'radius', 'tension']` +| `numbers` | `type` | `'number'` +| `colors` | `properties` | `['color', 'borderColor', 'backgroundColor']` | `colors` | `type` | `'color'` -| `colors` | `properties` | `['borderColor', 'backgroundColor']` - -Direct property configuration overrides configuration of same property in a collection. - -From collections, a property gets its configuration from first one that has its name in properties. :::note -These default collections are overridden by most dataset controllers. +These default animations are overridden by most of the dataset controllers. ::: +## transitions + +The core transitions are `'active'`, `'hide'`, `'reset'`, `'resize'`, `'show'`. +A custom transtion can be used by passing a custom `mode` to [update](../developers/api.md#updatemode). +Transition extends the main [animation configuration](#animation-configuration) and [animations configuration](#animations-configuration). + +### Default transitions + +Namespace: `options.transitions[mode]` + +| Mode | Option | Value | Description +| -----| ------ | ----- | ----- +| `'active'` | animation.duration | 400 | Override default duration to 400ms for hover animations +| `'resize'` | animation.duration | 0 | Override default duration to 0ms (= no animation) for resize +| `'show'` | animations.colors | `{ type: 'color', properties: ['borderColor', 'backgroundColor'], from: 'transparent' }` | Colors are faded in from transparent when dataset is shown using legend / [api](../developers/api.md#showdatasetIndex). +| `'show'` | animations.visible | `{ type: 'boolean', duration: 0 }` | Dataset visiblity is immediately changed to true so the color transition from transparent is visible. +| `'hide'` | animations.colors | `{ type: 'color', properties: ['borderColor', 'backgroundColor'], to: 'transparent' }` | Colors are faded to transparent when dataset id hidden using legend / [api](../developers/api.md#hidedatasetIndex). +| `'hide'` | animations.visible | `{ type: 'boolean', easing: 'easeInExpo' }` | Visibility is changed to false at a very late phase of animation + ## Disabling animation To disable an animation configuration, the animation node must be set to `false`, with the exception for animation modes which can be disabled by setting the `duration` to `0`. ```javascript -chart.options.animation = false; // disables the whole animation -chart.options.animation.active.duration = 0; // disables the animation for 'active' mode -chart.options.animation.colors = false; // disables animation defined by the collection of 'colors' properties -chart.options.animation.x = false; // disables animation defined by the 'x' property +chart.options.animation = false; // disables all animations +chart.options.animations.colors = false; // disables animation defined by the collection of 'colors' properties +chart.options.animations.x = false; // disables animation defined by the 'x' property +chart.options.transitions.active.animation.duration = 0; // disables the animation for 'active' mode ``` ## Easing diff --git a/docs/docs/developers/api.md b/docs/docs/developers/api.md index 60d1c6d08..0cb9841f1 100644 --- a/docs/docs/developers/api.md +++ b/docs/docs/developers/api.md @@ -28,7 +28,7 @@ myLineChart.data.datasets[0].data[2] = 50; // Would update the first dataset's v myLineChart.update(); // Calling update now animates the position of March from 90 to 50. ``` -A `mode` string can be provided to indicate what should be updated and what animation configuration should be used. Core calls this method using any of `'active'`, `'hide'`, `'reset'`, `'resize'`, `'show'` or `undefined`. `'none'` is also a supported mode for skipping animations for single update. Please see [animations](../configuration/animations.mdx) docs for more details. +A `mode` string can be provided to indicate transition configuration should be used. Core calls this method using any of `'active'`, `'hide'`, `'reset'`, `'resize'`, `'show'` or `undefined`. `'none'` is also a supported mode for skipping animations for single update. Please see [animations](../configuration/animations.mdx) docs for more details. Example: diff --git a/samples/animations/drop.html b/samples/animations/drop.html index 8af8d0178..b89b978a6 100644 --- a/samples/animations/drop.html +++ b/samples/animations/drop.html @@ -33,13 +33,13 @@ labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], datasets: [{ label: 'My First dataset', - animation: { + animations: { y: { duration: 2000, - delay: 100 + delay: 500 } }, - backgroundColor: window.chartColors.red, + backgroundColor: 'rgba(170,0,0,0.1)', borderColor: window.chartColors.red, data: [ randomScalingFactor(), @@ -50,7 +50,8 @@ randomScalingFactor(), randomScalingFactor() ], - fill: false, + fill: 1, + tension: 0.5 }, { label: 'My Second dataset', fill: false, @@ -68,10 +69,17 @@ }] }, options: { - animation: { + animations: { y: { easing: 'easeInOutElastic', - from: 0 + from: (ctx) => { + if (ctx.type === 'data') { + if (ctx.mode === 'default' && !ctx.dropped) { + ctx.dropped = true; + return 0; + } + } + } } }, responsive: true, diff --git a/samples/animations/loop.html b/samples/animations/loop.html index 9356a3f3a..d75e47f1b 100644 --- a/samples/animations/loop.html +++ b/samples/animations/loop.html @@ -62,7 +62,7 @@ }] }, options: { - animation: { + animations: { radius: { duration: 400, easing: 'linear', @@ -74,23 +74,18 @@ hoverRadius: 6 } }, - responsive: true, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false + }, plugins: { title: { display: true, text: 'Chart.js Line Chart' }, - tooltip: { - mode: 'nearest', - axis: 'x', - intersect: false, - }, - }, - hover: { - mode: 'nearest', - axis: 'x', - intersect: false }, + responsive: true, scales: { x: { display: true, diff --git a/src/controllers/controller.bar.js b/src/controllers/controller.bar.js index 1dfcc44df..1782d9127 100644 --- a/src/controllers/controller.bar.js +++ b/src/controllers/controller.bar.js @@ -520,7 +520,7 @@ BarController.defaults = { datasets: { categoryPercentage: 0.8, barPercentage: 0.9, - animation: { + animations: { numbers: { type: 'number', properties: ['x', 'y', 'base', 'width', 'height'] diff --git a/src/controllers/controller.bubble.js b/src/controllers/controller.bubble.js index f1f0e1ecf..348ea8866 100644 --- a/src/controllers/controller.bubble.js +++ b/src/controllers/controller.bubble.js @@ -133,8 +133,9 @@ BubbleController.id = 'bubble'; BubbleController.defaults = { datasetElementType: false, dataElementType: 'point', - animation: { + animations: { numbers: { + type: 'number', properties: ['x', 'y', 'borderWidth', 'radius'] } }, diff --git a/src/controllers/controller.doughnut.js b/src/controllers/controller.doughnut.js index e10fa42b9..c283a266a 100644 --- a/src/controllers/controller.doughnut.js +++ b/src/controllers/controller.doughnut.js @@ -329,15 +329,17 @@ DoughnutController.defaults = { datasetElementType: false, dataElementType: 'arc', animation: { - numbers: { - type: 'number', - properties: ['circumference', 'endAngle', 'innerRadius', 'outerRadius', 'startAngle', 'x', 'y', 'offset', 'borderWidth'] - }, // Boolean - Whether we animate the rotation of the Doughnut animateRotate: true, // Boolean - Whether we animate scaling the Doughnut from the centre animateScale: false }, + animations: { + numbers: { + type: 'number', + properties: ['circumference', 'endAngle', 'innerRadius', 'outerRadius', 'startAngle', 'x', 'y', 'offset', 'borderWidth'] + }, + }, aspectRatio: 1, datasets: { diff --git a/src/controllers/controller.polarArea.js b/src/controllers/controller.polarArea.js index a476c275a..49ac29898 100644 --- a/src/controllers/controller.polarArea.js +++ b/src/controllers/controller.polarArea.js @@ -122,12 +122,14 @@ PolarAreaController.id = 'polarArea'; PolarAreaController.defaults = { dataElementType: 'arc', animation: { + animateRotate: true, + animateScale: true + }, + animations: { numbers: { type: 'number', properties: ['x', 'y', 'startAngle', 'endAngle', 'innerRadius', 'outerRadius'] }, - animateRotate: true, - animateScale: true }, aspectRatio: 1, indexAxis: 'r', diff --git a/src/core/core.animation.js b/src/core/core.animation.js index 7be74bcef..dc78afd79 100644 --- a/src/core/core.animation.js +++ b/src/core/core.animation.js @@ -28,7 +28,7 @@ export default class Animation { this._active = true; this._fn = cfg.fn || interpolators[cfg.type || typeof from]; - this._easing = effects[cfg.easing || 'linear']; + this._easing = effects[cfg.easing] || effects.linear; this._start = Math.floor(Date.now() + (cfg.delay || 0)); this._duration = Math.floor(cfg.duration); this._loop = !!cfg.loop; diff --git a/src/core/core.animations.js b/src/core/core.animations.js index ff688b6c9..600aa71e1 100644 --- a/src/core/core.animations.js +++ b/src/core/core.animations.js @@ -1,20 +1,31 @@ import animator from './core.animator'; import Animation from './core.animation'; import defaults from './core.defaults'; -import {isObject} from '../helpers/helpers.core'; +import {isArray, isObject} from '../helpers/helpers.core'; const numbers = ['x', 'y', 'borderWidth', 'radius', 'tension']; -const colors = ['borderColor', 'backgroundColor']; -const animationOptions = ['delay', 'duration', 'easing', 'fn', 'from', 'loop', 'to', 'type']; +const colors = ['color', 'borderColor', 'backgroundColor']; defaults.set('animation', { - // Plain properties can be overridden in each object + delay: undefined, duration: 1000, easing: 'easeOutQuart', - onProgress: undefined, - onComplete: undefined, + fn: undefined, + from: undefined, + loop: undefined, + to: undefined, + type: undefined, +}); + +const animationOptions = Object.keys(defaults.animation); - // Property sets +defaults.describe('animation', { + _fallback: false, + _indexable: false, + _scriptable: (name) => name !== 'onProgress' && name !== 'onComplete' && name !== 'fn', +}); + +defaults.set('animations', { colors: { type: 'color', properties: colors @@ -23,60 +34,63 @@ defaults.set('animation', { type: 'number', properties: numbers }, +}); + +defaults.describe('animations', { + _fallback: 'animation', +}); - // Update modes. These are overrides / additions to the above animations. +defaults.set('transitions', { active: { - duration: 400 + animation: { + duration: 400 + } }, resize: { - duration: 0 + animation: { + duration: 0 + } }, show: { - colors: { - type: 'color', - properties: colors, - from: 'transparent' - }, - visible: { - type: 'boolean', - duration: 0 // show immediately - }, + animations: { + colors: { + from: 'transparent' + }, + visible: { + type: 'boolean', + duration: 0 // show immediately + }, + } }, hide: { - colors: { - type: 'color', - properties: colors, - to: 'transparent' - }, - visible: { - type: 'boolean', - fn: v => v < 1 ? 0 : 1 // for keeping the dataset visible all the way through the animation - }, + animations: { + colors: { + to: 'transparent' + }, + visible: { + type: 'boolean', + fn: v => v < 1 ? 0 : 1 // for keeping the dataset visible all the way through the animation + }, + } } }); -defaults.describe('animation', { - _scriptable: (name) => name !== 'onProgress' && name !== 'onComplete' && name !== 'fn', - _indexable: false, - _fallback: 'animation', -}); - export default class Animations { - constructor(chart, animations) { + constructor(chart, config) { this._chart = chart; this._properties = new Map(); - this.configure(animations); + this.configure(config); } - configure(animations) { - if (!isObject(animations)) { + configure(config) { + if (!isObject(config)) { return; } const animatedProps = this._properties; - Object.getOwnPropertyNames(animations).forEach(key => { - const cfg = animations[key]; + Object.getOwnPropertyNames(config).forEach(key => { + const cfg = config[key]; if (!isObject(cfg)) { return; } @@ -85,7 +99,7 @@ export default class Animations { resolved[option] = cfg[option]; } - (cfg.properties || [key]).forEach((prop) => { + (isArray(cfg.properties) && cfg.properties || [key]).forEach((prop) => { if (prop === key || !animatedProps.has(prop)) { animatedProps.set(prop, resolved); } diff --git a/src/core/core.config.js b/src/core/core.config.js index 8a47782c9..580082b29 100644 --- a/src/core/core.config.js +++ b/src/core/core.config.js @@ -166,11 +166,11 @@ export default class Config { } /** - * Returns the option scope keys for resolving dataset options. - * These keys do not include the dataset itself, because it is not under options. - * @param {string} datasetType - * @return {string[]} - */ + * Returns the option scope keys for resolving dataset options. + * These keys do not include the dataset itself, because it is not under options. + * @param {string} datasetType + * @return {string[]} + */ datasetScopeKeys(datasetType) { return cachedKeys(datasetType, () => [ @@ -182,29 +182,34 @@ export default class Config { } /** - * Returns the option scope keys for resolving dataset animation options. - * These keys do not include the dataset itself, because it is not under options. - * @param {string} datasetType - * @return {string[]} - */ - datasetAnimationScopeKeys(datasetType) { - return cachedKeys(`${datasetType}.animation`, + * Returns the option scope keys for resolving dataset animation options. + * These keys do not include the dataset itself, because it is not under options. + * @param {string} datasetType + * @param {string} transition + * @return {string[]} + */ + datasetAnimationScopeKeys(datasetType, transition) { + return cachedKeys(`${datasetType}.transition.${transition}`, () => [ - `datasets.${datasetType}.animation`, - `controllers.${datasetType}.animation`, - `controllers.${datasetType}.datasets.animation`, - 'animation' + `datasets.${datasetType}.transitions.${transition}`, + `controllers.${datasetType}.transitions.${transition}`, + `controllers.${datasetType}.datasets.transitions.${transition}`, + `transitions.${transition}`, + `datasets.${datasetType}`, + `controllers.${datasetType}`, + `controllers.${datasetType}.datasets`, + '' ]); } /** - * Returns the options scope keys for resolving element options that belong - * to an dataset. These keys do not include the dataset itself, because it - * is not under options. - * @param {string} datasetType - * @param {string} elementType - * @return {string[]} - */ + * Returns the options scope keys for resolving element options that belong + * to an dataset. These keys do not include the dataset itself, because it + * is not under options. + * @param {string} datasetType + * @param {string} elementType + * @return {string[]} + */ datasetElementScopeKeys(datasetType, elementType) { return cachedKeys(`${datasetType}-${elementType}`, () => [ @@ -219,7 +224,7 @@ export default class Config { /** * Returns the options scope keys for resolving plugin options. * @param {{id: string, additionalOptionScopes?: string[]}} plugin - * @return {string[]} + * @return {string[]} */ pluginScopeKeys(plugin) { const id = plugin.id; @@ -233,11 +238,11 @@ export default class Config { } /** - * Resolves the objects from options and defaults for option value resolution. - * @param {object} mainScope - The main scope object for options - * @param {string[]} scopeKeys - The keys in resolution order + * Resolves the objects from options and defaults for option value resolution. + * @param {object} mainScope - The main scope object for options + * @param {string[]} scopeKeys - The keys in resolution order * @param {boolean} [resetCache] - reset the cache for this mainScope - */ + */ getOptionScopes(mainScope, scopeKeys, resetCache) { let cache = this._scopeCache.get(mainScope); if (!cache || resetCache) { @@ -267,9 +272,9 @@ export default class Config { } /** - * Returns the option scopes for resolving chart options - * @return {object[]} - */ + * Returns the option scopes for resolving chart options + * @return {object[]} + */ chartOptionScopes() { return [ this.options, @@ -281,12 +286,12 @@ export default class Config { } /** - * @param {object[]} scopes - * @param {string[]} names - * @param {function|object} context - * @param {string[]} [prefixes] - * @return {object} - */ + * @param {object[]} scopes + * @param {string[]} names + * @param {function|object} context + * @param {string[]} [prefixes] + * @return {object} + */ resolveNamedOptions(scopes, names, context, prefixes = ['']) { const result = {$shared: true}; const {resolver, subPrefixes} = getResolver(this._resolverCache, scopes, prefixes); @@ -306,10 +311,10 @@ export default class Config { } /** - * @param {object[]} scopes - * @param {object} [context] + * @param {object[]} scopes + * @param {object} [context] * @param {string[]} [prefixes] - */ + */ createResolver(scopes, context, prefixes = ['']) { const {resolver} = getResolver(this._resolverCache, scopes, prefixes); return isObject(context) @@ -342,7 +347,7 @@ function needContext(proxy, names) { for (const prop of names) { if ((isScriptable(prop) && isFunction(proxy[prop])) - || (isIndexable(prop) && isArray(proxy[prop]))) { + || (isIndexable(prop) && isArray(proxy[prop]))) { return true; } } diff --git a/src/core/core.datasetController.js b/src/core/core.datasetController.js index 615c55ddc..07b1795ba 100644 --- a/src/core/core.datasetController.js +++ b/src/core/core.datasetController.js @@ -763,11 +763,11 @@ export default class DatasetController { /** * @private */ - _resolveAnimations(index, mode, active) { + _resolveAnimations(index, transition, active) { const me = this; const chart = me.chart; const cache = me._cachedDataOpts; - const cacheKey = 'animation-' + mode; + const cacheKey = `animation-${transition}`; const cached = cache[cacheKey]; if (cached) { return cached; @@ -775,11 +775,11 @@ export default class DatasetController { let options; if (chart.options.animation !== false) { const config = me.chart.config; - const scopeKeys = config.datasetAnimationScopeKeys(me._type); - const scopes = config.getOptionScopes(me.getDataset().animation, scopeKeys); - options = config.createResolver(scopes, me.getContext(index, active, mode)); + const scopeKeys = config.datasetAnimationScopeKeys(me._type, transition); + const scopes = config.getOptionScopes(me.getDataset(), scopeKeys); + options = config.createResolver(scopes, me.getContext(index, active, transition)); } - const animations = new Animations(chart, options && options[mode] || options); + const animations = new Animations(chart, options && options.animations); if (options && options._cacheable) { cache[cacheKey] = Object.freeze(animations); } diff --git a/src/core/core.scale.js b/src/core/core.scale.js index 26181bb9c..fe468b9ad 100644 --- a/src/core/core.scale.js +++ b/src/core/core.scale.js @@ -83,12 +83,16 @@ defaults.route('scale.ticks', 'color', '', 'color'); defaults.route('scale.gridLines', 'color', '', 'borderColor'); defaults.route('scale.scaleLabel', 'color', '', 'color'); -defaults.describe('scales', { - _fallback: 'scale', +defaults.describe('scale', { + _fallback: false, _scriptable: (name) => !name.startsWith('before') && !name.startsWith('after') && name !== 'callback' && name !== 'parser', _indexable: (name) => name !== 'borderDash' && name !== 'tickBorderDash', }); +defaults.describe('scales', { + _fallback: 'scale', +}); + /** * Returns a new array containing numItems from arr * @param {any[]} arr diff --git a/src/helpers/helpers.config.js b/src/helpers/helpers.config.js index a785fa39a..3af16a66a 100644 --- a/src/helpers/helpers.config.js +++ b/src/helpers/helpers.config.js @@ -1,18 +1,25 @@ -import {defined, isArray, isFunction, isObject, resolveObjectKey, valueOrDefault, _capitalize} from './helpers.core'; +import {defined, isArray, isFunction, isObject, resolveObjectKey, _capitalize} from './helpers.core'; /** * Creates a Proxy for resolving raw values for options. * @param {object[]} scopes - The option scopes to look for values, in resolution order * @param {string[]} [prefixes] - The prefixes for values, in resolution order. + * @param {object[]} [rootScopes] - The root option scopes + * @param {string|boolean} [fallback] - Parent scopes fallback * @returns Proxy * @private */ -export function _createResolver(scopes, prefixes = ['']) { +export function _createResolver(scopes, prefixes = [''], rootScopes = scopes, fallback) { + if (!defined(fallback)) { + fallback = _resolve('_fallback', scopes); + } const cache = { [Symbol.toStringTag]: 'Object', _cacheable: true, _scopes: scopes, - override: (scope) => _createResolver([scope, ...scopes], prefixes), + _rootScopes: rootScopes, + _fallback: fallback, + override: (scope) => _createResolver([scope, ...scopes], prefixes, rootScopes, fallback), }; return new Proxy(cache, { /** @@ -20,7 +27,7 @@ export function _createResolver(scopes, prefixes = ['']) { */ get(target, prop) { return _cached(target, prop, - () => _resolveWithPrefixes(prop, prefixes, scopes)); + () => _resolveWithPrefixes(prop, prefixes, scopes, target)); }, /** @@ -186,7 +193,7 @@ function _resolveScriptable(prop, value, target, receiver) { _stack.delete(prop); if (isObject(value)) { // When scriptable option returns an object, create a resolver on that. - value = createSubResolver(_proxy._scopes, prop, value); + value = createSubResolver(_proxy._scopes, _proxy, prop, value); } return value; } @@ -202,64 +209,69 @@ function _resolveArray(prop, value, target, isIndexable) { const scopes = _proxy._scopes.filter(s => s !== arr); value = []; for (const item of arr) { - const resolver = createSubResolver(scopes, prop, item); + const resolver = createSubResolver(scopes, _proxy, prop, item); value.push(_attachContext(resolver, _context, _subProxy && _subProxy[prop])); } } return value; } -function createSubResolver(parentScopes, prop, value) { - const set = new Set([value]); - const lookupScopes = [value, ...parentScopes]; - const {keys, includeParents} = _resolveSubKeys(lookupScopes, prop, value); - while (keys.length) { - const key = keys.shift(); - for (const item of lookupScopes) { - const scope = resolveObjectKey(item, key); - if (scope) { - set.add(scope); - // fallback detour? - const fallback = scope._fallback; - if (defined(fallback)) { - keys.push(...resolveFallback(fallback, key, scope).filter(k => k !== key)); - } +function resolveFallback(fallback, prop, value) { + return isFunction(fallback) ? fallback(prop, value) : fallback; +} - } else if (key !== prop && scope === false) { - // If any of the fallback scopes is explicitly false, return false - // For example, options.hover falls back to options.interaction, when - // options.interaction is false, options.hover will also resolve as false. - return false; +const getScope = (key, parent) => key === true ? parent : resolveObjectKey(parent, key); + +function addScopes(set, parentScopes, key, parentFallback) { + for (const parent of parentScopes) { + const scope = getScope(key, parent); + if (scope) { + set.add(scope); + const fallback = scope._fallback; + if (defined(fallback) && fallback !== key && fallback !== parentFallback) { + // When we reach the descriptor that defines a new _fallback, return that. + // The fallback will resume to that new scope. + return fallback; } + } else if (scope === false && key !== 'fill') { + // Fallback to `false` results to `false`, expect for `fill`. + // The special case (fill) should be handled through descriptors. + return null; } } - if (includeParents) { - parentScopes.forEach(set.add, set); - } - return _createResolver([...set]); -} - -function resolveFallback(fallback, prop, value) { - const resolved = isFunction(fallback) ? fallback(prop, value) : fallback; - return isArray(resolved) ? resolved : typeof resolved === 'string' ? [resolved] : []; + return false; } -function _resolveSubKeys(parentScopes, prop, value) { - const fallback = valueOrDefault(_resolve('_fallback', parentScopes.map(scope => scope[prop] || scope)), true); - const keys = [prop]; - if (defined(fallback)) { - keys.push(...resolveFallback(fallback, prop, value)); +function createSubResolver(parentScopes, resolver, prop, value) { + const rootScopes = resolver._rootScopes; + const fallback = resolveFallback(resolver._fallback, prop, value); + const allScopes = [...parentScopes, ...rootScopes]; + const set = new Set([value]); + let key = prop; + while (key !== false) { + key = addScopes(set, allScopes, key, fallback); + if (key === null) { + return false; + } } - return {keys: keys.filter(v => v), includeParents: fallback !== false && fallback !== prop}; + if (defined(fallback) && fallback !== prop) { + const fallbackScopes = allScopes; + key = fallback; + while (key !== false) { + key = addScopes(set, fallbackScopes, key, fallback); + } + } + return _createResolver([...set], [''], rootScopes, fallback); } -function _resolveWithPrefixes(prop, prefixes, scopes) { + +function _resolveWithPrefixes(prop, prefixes, scopes, proxy) { let value; for (const prefix of prefixes) { value = _resolve(readKey(prefix, prop), scopes); if (defined(value)) { - return (needsSubResolver(prop, value)) - ? createSubResolver(scopes, prop, value) + return needsSubResolver(prop, value) + ? createSubResolver(scopes, proxy, prop, value) : value; } } diff --git a/src/plugins/plugin.tooltip.js b/src/plugins/plugin.tooltip.js index 40b0fbbf6..1bc89d95c 100644 --- a/src/plugins/plugin.tooltip.js +++ b/src/plugins/plugin.tooltip.js @@ -385,9 +385,11 @@ export class Tooltip extends Element { const chart = me._chart; const options = me.options; - const opts = options.enabled && chart.options.animation && options.animation; + const opts = options.enabled && chart.options.animation && options.animations; const animations = new Animations(me._chart, opts); - me._cachedAnimations = Object.freeze(animations); + if (opts._cacheable) { + me._cachedAnimations = Object.freeze(animations); + } return animations; } @@ -1108,6 +1110,8 @@ export default { animation: { duration: 400, easing: 'easeOutQuart', + }, + animations: { numbers: { type: 'number', properties: ['x', 'y', 'width', 'height', 'caretX', 'caretY'], @@ -1203,6 +1207,12 @@ export default { callbacks: { _scriptable: false, _indexable: false, + }, + animation: { + _fallback: false + }, + animations: { + _fallback: 'animation' } }, diff --git a/test/specs/core.datasetController.tests.js b/test/specs/core.datasetController.tests.js index 028b3077f..c65510aad 100644 --- a/test/specs/core.datasetController.tests.js +++ b/test/specs/core.datasetController.tests.js @@ -742,6 +742,18 @@ describe('Chart.DatasetController', function() { }); describe('_resolveAnimations', function() { + function animationsExpectations(anims, props) { + for (const [prop, opts] of Object.entries(props)) { + const anim = anims._properties.get(prop); + expect(anim).withContext(prop).toBeInstanceOf(Object); + if (anim) { + for (const [name, value] of Object.entries(opts)) { + expect(anim[name]).withContext('"' + name + '" of ' + JSON.stringify(anim)).toEqual(value); + } + } + } + } + it('should resolve to empty Animations when globally disabled', function() { const chart = acquireChart({ type: 'line', @@ -778,5 +790,70 @@ describe('Chart.DatasetController', function() { expect(controller._resolveAnimations(0)._properties.size).toEqual(0); }); + + it('should fallback properly', function() { + const chart = acquireChart({ + type: 'line', + data: { + datasets: [{ + data: [1], + animation: { + duration: 200 + } + }, { + type: 'bar', + data: [2] + }] + }, + options: { + animation: { + delay: 100 + }, + animations: { + x: { + delay: 200 + } + }, + transitions: { + show: { + x: { + delay: 300 + } + } + }, + datasets: { + bar: { + animation: { + duration: 500 + } + } + } + } + }); + const controller = chart.getDatasetMeta(0).controller; + + expect(Chart.defaults.animation.duration).toEqual(1000); + + const def0 = controller._resolveAnimations(0, 'default', false); + animationsExpectations(def0, { + x: { + delay: 200, + duration: 200 + }, + y: { + delay: 100, + duration: 200 + } + }); + + const controller2 = chart.getDatasetMeta(1).controller; + const def1 = controller2._resolveAnimations(0, 'default', false); + animationsExpectations(def1, { + x: { + delay: 200, + duration: 500 + } + }); + }); }); }); diff --git a/test/specs/helpers.config.tests.js b/test/specs/helpers.config.tests.js index b79c0b9b8..86bee170e 100644 --- a/test/specs/helpers.config.tests.js +++ b/test/specs/helpers.config.tests.js @@ -17,7 +17,10 @@ describe('Chart.helpers.config', function() { expect(resolver.hoverColor).toEqual(defaults.hoverColor); }); - it('should resolve to parent scopes', function() { + it('should resolve to parent scopes, when _fallback is true', function() { + const descriptors = { + _fallback: true + }; const defaults = { root: true, sub: { @@ -28,7 +31,7 @@ describe('Chart.helpers.config', function() { child: 'sub default comes before this', opt: 'opt' }; - const resolver = _createResolver([options, defaults]); + const resolver = _createResolver([options, defaults, descriptors]); const sub = resolver.sub; expect(sub.root).toEqual(true); expect(sub.child).toEqual(true); @@ -125,10 +128,9 @@ describe('Chart.helpers.config', function() { }); }); - it('should not fallback when _fallback is false', function() { + it('should not fallback by default', function() { const defaults = { hover: { - _fallback: false, a: 'defaults.hover' }, controllers: { @@ -252,16 +254,23 @@ describe('Chart.helpers.config', function() { }); it('should fallback throuhg multiple routes', function() { + const descriptors = { + _fallback: 'level1', + level1: { + _fallback: 'root' + }, + level2: { + _fallback: 'level1' + } + }; const defaults = { root: { a: 'root' }, level1: { - _fallback: 'root', b: 'level1', }, level2: { - _fallback: 'level1', level1: { g: 'level2.level1' }, @@ -277,7 +286,7 @@ describe('Chart.helpers.config', function() { } } }; - const resolver = _createResolver([defaults]); + const resolver = _createResolver([defaults, descriptors]); expect(resolver.level1).toEqualOptions({ a: 'root', b: 'level1', @@ -292,7 +301,7 @@ describe('Chart.helpers.config', function() { expect(resolver.level2.sublevel1).toEqualOptions({ a: 'root', b: 'level1', - c: 'level2', // TODO: this should be undefined + c: undefined, d: 'sublevel1', e: undefined, f: undefined, @@ -301,7 +310,7 @@ describe('Chart.helpers.config', function() { expect(resolver.level2.sublevel2).toEqualOptions({ a: 'root', b: 'level1', - c: 'level2', // TODO: this should be undefined + c: undefined, d: undefined, e: 'sublevel2', f: undefined, @@ -310,13 +319,129 @@ describe('Chart.helpers.config', function() { expect(resolver.level2.sublevel2.level1).toEqualOptions({ a: 'root', b: 'level1', - c: 'level2', // TODO: this should be undefined + c: undefined, d: undefined, - e: 'sublevel2', // TODO: this should be undefined + e: undefined, f: 'sublevel2.level1', - g: 'level2.level1' + g: undefined // same key only included from immediate parents and root + }); + }); + + it('should fallback through multiple routes (animations)', function() { + const descriptors = { + animations: { + _fallback: 'animation', + }, + }; + const defaults = { + animation: { + duration: 1000, + easing: 'easeInQuad' + }, + animations: { + colors: { + properties: ['color', 'backgroundColor'], + type: 'color' + }, + numbers: { + properties: ['x', 'y'], + type: 'number' + } + }, + transitions: { + resize: { + animation: { + duration: 0 + } + }, + show: { + animation: { + duration: 400 + }, + animations: { + colors: { + from: 'transparent' + } + } + } + } + }; + const options = { + animation: { + easing: 'linear' + }, + animations: { + colors: { + properties: ['color', 'borderColor', 'backgroundColor'], + }, + duration: { + properties: ['a', 'b'], + type: 'boolean' + } + } + }; + + const show = _createResolver([options, defaults.transitions.show, defaults, descriptors]); + expect(show.animation).toEqualOptions({ + duration: 400, + easing: 'linear' + }); + expect(show.animations.colors._scopes).toEqual([ + options.animations.colors, + defaults.transitions.show.animations.colors, + defaults.animations.colors, + options.animation, + defaults.transitions.show.animation, + defaults.animation + ]); + expect(show.animations.colors).toEqualOptions({ + duration: 400, + from: 'transparent', + easing: 'linear', + type: 'color', + properties: ['color', 'borderColor', 'backgroundColor'] }); + expect(show.animations.duration).toEqualOptions({ + duration: 400, + easing: 'linear', + type: 'boolean', + properties: ['a', 'b'] + }); + expect(Object.getOwnPropertyNames(show.animations).filter(k => Chart.helpers.isObject(show.animations[k]))).toEqual([ + 'colors', + 'duration', + 'numbers', + ]); + const def = _createResolver([options, defaults, descriptors]); + expect(def.animation).toEqualOptions({ + duration: 1000, + easing: 'linear' + }); + expect(def.animations.colors._scopes).toEqual([ + options.animations.colors, + defaults.animations.colors, + options.animation, + defaults.animation + ]); + expect(def.animations.colors).toEqualOptions({ + duration: 1000, + easing: 'linear', + type: 'color', + properties: ['color', 'borderColor', 'backgroundColor'] + }); + expect(def.animations.duration).toEqualOptions({ + duration: 1000, + easing: 'linear', + type: 'boolean', + properties: ['a', 'b'] + }); + expect(Object.getOwnPropertyNames(def.animations).filter(k => Chart.helpers.isObject(show.animations[k]))).toEqual([ + 'colors', + 'duration', + 'numbers', + ]); }); + }); }); diff --git a/types/index.esm.d.ts b/types/index.esm.d.ts index 5cde43d05..46c146534 100644 --- a/types/index.esm.d.ts +++ b/types/index.esm.d.ts @@ -1335,12 +1335,9 @@ export interface HoverInteractionOptions extends CoreInteractionOptions { onHover(event: ChartEvent, elements: ActiveElement[], chart: Chart): void; } -export interface CoreChartOptions extends ParsingOptions { - animation: Scriptable; +export interface CoreChartOptions extends ParsingOptions, AnimationOptions { - datasets: { - animation: Scriptable; - }; + datasets: AnimationOptions; /** * The base axis of the chart. 'x' for vertical charts and 'y' for horizontal charts. @@ -1460,76 +1457,81 @@ export type EasingFunction = | 'easeOutBounce' | 'easeInOutBounce'; -export interface AnimationCommonSpec { +export type AnimationSpec = { /** * The number of milliseconds an animation takes. * @default 1000 */ - duration: number; + duration: Scriptable; /** * Easing function to use * @default 'easeOutQuart' */ - easing: EasingFunction; + easing: Scriptable; /** * Running animation count + FPS display in upper left corner of the chart. * @default false */ - debug: boolean; + debug: Scriptable; /** * Delay before starting the animations. * @default 0 */ - delay: number; + delay: Scriptable; /** * If set to true, the animations loop endlessly. * @default false */ - loop: boolean; + loop: Scriptable; } -export interface AnimationPropertySpec extends AnimationCommonSpec { - properties: string[]; +export type AnimationsSpec = { + [name: string]: AnimationSpec & { + properties: string[]; - /** - * Type of property, determines the interpolator used. Possible values: 'number', 'color' and 'boolean'. Only really needed for 'color', because typeof does not get that right. - */ - type: 'color' | 'number' | 'boolean'; + /** + * Type of property, determines the interpolator used. Possible values: 'number', 'color' and 'boolean'. Only really needed for 'color', because typeof does not get that right. + */ + type: 'color' | 'number' | 'boolean'; - fn: (from: T, to: T, factor: number) => T; + fn: (from: T, to: T, factor: number) => T; - /** - * Start value for the animation. Current value is used when undefined - */ - from: Color | number | boolean; - /** - * - */ - to: Color | number | boolean; + /** + * Start value for the animation. Current value is used when undefined + */ + from: Scriptable; + /** + * + */ + to: Scriptable; + } | false } -export type AnimationSpecContainer = AnimationCommonSpec & { - [prop: string]: AnimationPropertySpec | false; -}; +export type TransitionSpec = { + animation: AnimationSpec; + animations: AnimationsSpec; +} -export type AnimationOptions = AnimationSpecContainer & { - /** - * Callback called on each step of an animation. - */ - onProgress: (this: Chart, event: AnimationEvent) => void; - /** - * Callback called when all animations are completed. - */ - onComplete: (this: Chart, event: AnimationEvent) => void; +export type TransitionsSpec = { + [mode: string]: TransitionSpec +} - active: AnimationSpecContainer | false; - hide: AnimationSpecContainer | false; - reset: AnimationSpecContainer | false; - resize: AnimationSpecContainer | false; - show: AnimationSpecContainer | false; +export type AnimationOptions = { + animation: AnimationSpec & { + /** + * Callback called on each step of an animation. + */ + onProgress: (this: Chart, event: AnimationEvent) => void; + /** + * Callback called when all animations are completed. + */ + onComplete: (this: Chart, event: AnimationEvent) => void; + }; + animations: AnimationsSpec; + transitions: TransitionsSpec; }; export interface FontSpec { @@ -2452,7 +2454,9 @@ export interface TooltipOptions extends CoreInteractionOptions { */ textDirection: string; - animation: Scriptable; + animation: AnimationSpec; + + animations: AnimationsSpec; callbacks: TooltipCallbacks; }