Isolate properties / modes from animation options (#8332)

* Isolate properties / modes from animation options
* tabs, something wrong with the linter
* Update misleading variable name
This commit is contained in:
Jukka Kurkela 2021-02-20 16:02:22 +02:00 committed by GitHub
parent 284e357fd3
commit 5d5e48d01b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 570 additions and 310 deletions

View File

@ -33,7 +33,7 @@ function example() {
}] }]
}, },
options: { options: {
animation: { animations: {
tension: { tension: {
duration: 1000, duration: 1000,
easing: 'linear', easing: 'linear',
@ -77,7 +77,7 @@ function example() {
}] }]
}, },
options: { options: {
animation: { transitions: {
show: { show: {
x: { x: {
from: 0 from: 0
@ -107,10 +107,30 @@ function example() {
</TabItem> </TabItem>
</Tabs> </Tabs>
## Animation Configuration ## Animation configuration
The default configuration is defined here: <a href="https://github.com/chartjs/Chart.js/blob/master/src/core/core.animations.js#L6-L55" target="_blank">core.animations.js</a> Animation configuration consists of 3 keys.
Namespace: `options.animation`, the global options are defined in `Chart.defaults.animation`.
| Name | Type | Details
| ---- | ---- | -------
| animation | `object` | [animation](#animation)
| animations | `object` | [animations](#animations)
| transitions | `object` | [transitions](#transitions)
These keys can be configured in following paths:
* `` - 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: <a href="https://github.com/chartjs/Chart.js/blob/master/src/core/core.animations.js#L9-L56" target="_blank">core.animations.js</a>
Namespace: `options.animation`
| Name | Type | Default | Description | 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. | `debug` | `boolean` | `undefined` | Running animation count + FPS display in upper left corner of the chart.
| `delay` | `number` | `undefined` | Delay before starting the animations. | `delay` | `number` | `undefined` | Delay before starting the animations.
| `loop` | `boolean` | `undefined` | If set to `true`, the animations loop endlessly. | `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). 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. Animations options configures which element properties are animated and how.
The cores modes are `'active'`, `'hide'`, `'reset'`, `'resize'`, `'show'`. In addition to the main [animation configuration](#animation-configuration), the following options are available:
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).
### Default modes Namespace: `options.animations[animation]`
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]`
| Name | Type | Default | Description | 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. | `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` | `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` | `to` | `number`\|`Color`\|`boolean` | `undefined` | End value for the animation. Updated value is used when `undefined`
| `fn` | <code>&lt;T&gt;(from: T, to: T, factor: number) => T;</code> | `undefined` | Optional custom interpolator, instead of using a predefined interpolator from `type` | | `fn` | <code>&lt;T&gt;(from: T, to: T, factor: number) => T;</code> | `undefined` | Optional custom interpolator, instead of using a predefined interpolator from `type` |
## Animation properties collection configuration ### Default animations
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
| Name | Option | Value | Name | Option | Value
| ---- | ------ | ----- | ---- | ------ | -----
| `numbers` | `type` | `'number'`
| `numbers` | `properties` | `['x', 'y', 'borderWidth', 'radius', 'tension']` | `numbers` | `properties` | `['x', 'y', 'borderWidth', 'radius', 'tension']`
| `numbers` | `type` | `'number'`
| `colors` | `properties` | `['color', 'borderColor', 'backgroundColor']`
| `colors` | `type` | `'color'` | `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 :::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 ## 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`. 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 ```javascript
chart.options.animation = false; // disables the whole animation chart.options.animation = false; // disables all animations
chart.options.animation.active.duration = 0; // disables the animation for 'active' mode chart.options.animations.colors = false; // disables animation defined by the collection of 'colors' properties
chart.options.animation.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.animation.x = false; // disables animation defined by the 'x' property chart.options.transitions.active.animation.duration = 0; // disables the animation for 'active' mode
``` ```
## Easing ## Easing

View File

@ -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. 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: Example:

View File

@ -33,13 +33,13 @@
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
datasets: [{ datasets: [{
label: 'My First dataset', label: 'My First dataset',
animation: { animations: {
y: { y: {
duration: 2000, duration: 2000,
delay: 100 delay: 500
} }
}, },
backgroundColor: window.chartColors.red, backgroundColor: 'rgba(170,0,0,0.1)',
borderColor: window.chartColors.red, borderColor: window.chartColors.red,
data: [ data: [
randomScalingFactor(), randomScalingFactor(),
@ -50,7 +50,8 @@
randomScalingFactor(), randomScalingFactor(),
randomScalingFactor() randomScalingFactor()
], ],
fill: false, fill: 1,
tension: 0.5
}, { }, {
label: 'My Second dataset', label: 'My Second dataset',
fill: false, fill: false,
@ -68,10 +69,17 @@
}] }]
}, },
options: { options: {
animation: { animations: {
y: { y: {
easing: 'easeInOutElastic', easing: 'easeInOutElastic',
from: 0 from: (ctx) => {
if (ctx.type === 'data') {
if (ctx.mode === 'default' && !ctx.dropped) {
ctx.dropped = true;
return 0;
}
}
}
} }
}, },
responsive: true, responsive: true,

View File

@ -62,7 +62,7 @@
}] }]
}, },
options: { options: {
animation: { animations: {
radius: { radius: {
duration: 400, duration: 400,
easing: 'linear', easing: 'linear',
@ -74,23 +74,18 @@
hoverRadius: 6 hoverRadius: 6
} }
}, },
responsive: true, interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: 'Chart.js Line Chart' text: 'Chart.js Line Chart'
}, },
tooltip: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
},
hover: {
mode: 'nearest',
axis: 'x',
intersect: false
}, },
responsive: true,
scales: { scales: {
x: { x: {
display: true, display: true,

View File

@ -520,7 +520,7 @@ BarController.defaults = {
datasets: { datasets: {
categoryPercentage: 0.8, categoryPercentage: 0.8,
barPercentage: 0.9, barPercentage: 0.9,
animation: { animations: {
numbers: { numbers: {
type: 'number', type: 'number',
properties: ['x', 'y', 'base', 'width', 'height'] properties: ['x', 'y', 'base', 'width', 'height']

View File

@ -133,8 +133,9 @@ BubbleController.id = 'bubble';
BubbleController.defaults = { BubbleController.defaults = {
datasetElementType: false, datasetElementType: false,
dataElementType: 'point', dataElementType: 'point',
animation: { animations: {
numbers: { numbers: {
type: 'number',
properties: ['x', 'y', 'borderWidth', 'radius'] properties: ['x', 'y', 'borderWidth', 'radius']
} }
}, },

View File

@ -329,15 +329,17 @@ DoughnutController.defaults = {
datasetElementType: false, datasetElementType: false,
dataElementType: 'arc', dataElementType: 'arc',
animation: { animation: {
numbers: {
type: 'number',
properties: ['circumference', 'endAngle', 'innerRadius', 'outerRadius', 'startAngle', 'x', 'y', 'offset', 'borderWidth']
},
// Boolean - Whether we animate the rotation of the Doughnut // Boolean - Whether we animate the rotation of the Doughnut
animateRotate: true, animateRotate: true,
// Boolean - Whether we animate scaling the Doughnut from the centre // Boolean - Whether we animate scaling the Doughnut from the centre
animateScale: false animateScale: false
}, },
animations: {
numbers: {
type: 'number',
properties: ['circumference', 'endAngle', 'innerRadius', 'outerRadius', 'startAngle', 'x', 'y', 'offset', 'borderWidth']
},
},
aspectRatio: 1, aspectRatio: 1,
datasets: { datasets: {

View File

@ -122,12 +122,14 @@ PolarAreaController.id = 'polarArea';
PolarAreaController.defaults = { PolarAreaController.defaults = {
dataElementType: 'arc', dataElementType: 'arc',
animation: { animation: {
animateRotate: true,
animateScale: true
},
animations: {
numbers: { numbers: {
type: 'number', type: 'number',
properties: ['x', 'y', 'startAngle', 'endAngle', 'innerRadius', 'outerRadius'] properties: ['x', 'y', 'startAngle', 'endAngle', 'innerRadius', 'outerRadius']
}, },
animateRotate: true,
animateScale: true
}, },
aspectRatio: 1, aspectRatio: 1,
indexAxis: 'r', indexAxis: 'r',

View File

@ -28,7 +28,7 @@ export default class Animation {
this._active = true; this._active = true;
this._fn = cfg.fn || interpolators[cfg.type || typeof from]; 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._start = Math.floor(Date.now() + (cfg.delay || 0));
this._duration = Math.floor(cfg.duration); this._duration = Math.floor(cfg.duration);
this._loop = !!cfg.loop; this._loop = !!cfg.loop;

View File

@ -1,20 +1,31 @@
import animator from './core.animator'; import animator from './core.animator';
import Animation from './core.animation'; import Animation from './core.animation';
import defaults from './core.defaults'; 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 numbers = ['x', 'y', 'borderWidth', 'radius', 'tension'];
const colors = ['borderColor', 'backgroundColor']; const colors = ['color', 'borderColor', 'backgroundColor'];
const animationOptions = ['delay', 'duration', 'easing', 'fn', 'from', 'loop', 'to', 'type'];
defaults.set('animation', { defaults.set('animation', {
// Plain properties can be overridden in each object delay: undefined,
duration: 1000, duration: 1000,
easing: 'easeOutQuart', easing: 'easeOutQuart',
onProgress: undefined, fn: undefined,
onComplete: undefined, from: undefined,
loop: undefined,
to: undefined,
type: undefined,
});
// Property sets const animationOptions = Object.keys(defaults.animation);
defaults.describe('animation', {
_fallback: false,
_indexable: false,
_scriptable: (name) => name !== 'onProgress' && name !== 'onComplete' && name !== 'fn',
});
defaults.set('animations', {
colors: { colors: {
type: 'color', type: 'color',
properties: colors properties: colors
@ -23,29 +34,37 @@ defaults.set('animation', {
type: 'number', type: 'number',
properties: numbers properties: numbers
}, },
});
// Update modes. These are overrides / additions to the above animations. defaults.describe('animations', {
_fallback: 'animation',
});
defaults.set('transitions', {
active: { active: {
animation: {
duration: 400 duration: 400
}
}, },
resize: { resize: {
animation: {
duration: 0 duration: 0
}
}, },
show: { show: {
animations: {
colors: { colors: {
type: 'color',
properties: colors,
from: 'transparent' from: 'transparent'
}, },
visible: { visible: {
type: 'boolean', type: 'boolean',
duration: 0 // show immediately duration: 0 // show immediately
}, },
}
}, },
hide: { hide: {
animations: {
colors: { colors: {
type: 'color',
properties: colors,
to: 'transparent' to: 'transparent'
}, },
visible: { visible: {
@ -53,30 +72,25 @@ defaults.set('animation', {
fn: v => v < 1 ? 0 : 1 // for keeping the dataset visible all the way through the animation 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 { export default class Animations {
constructor(chart, animations) { constructor(chart, config) {
this._chart = chart; this._chart = chart;
this._properties = new Map(); this._properties = new Map();
this.configure(animations); this.configure(config);
} }
configure(animations) { configure(config) {
if (!isObject(animations)) { if (!isObject(config)) {
return; return;
} }
const animatedProps = this._properties; const animatedProps = this._properties;
Object.getOwnPropertyNames(animations).forEach(key => { Object.getOwnPropertyNames(config).forEach(key => {
const cfg = animations[key]; const cfg = config[key];
if (!isObject(cfg)) { if (!isObject(cfg)) {
return; return;
} }
@ -85,7 +99,7 @@ export default class Animations {
resolved[option] = cfg[option]; resolved[option] = cfg[option];
} }
(cfg.properties || [key]).forEach((prop) => { (isArray(cfg.properties) && cfg.properties || [key]).forEach((prop) => {
if (prop === key || !animatedProps.has(prop)) { if (prop === key || !animatedProps.has(prop)) {
animatedProps.set(prop, resolved); animatedProps.set(prop, resolved);
} }

View File

@ -185,15 +185,20 @@ export default class Config {
* Returns the option scope keys for resolving dataset animation options. * Returns the option scope keys for resolving dataset animation options.
* These keys do not include the dataset itself, because it is not under options. * These keys do not include the dataset itself, because it is not under options.
* @param {string} datasetType * @param {string} datasetType
* @param {string} transition
* @return {string[]} * @return {string[]}
*/ */
datasetAnimationScopeKeys(datasetType) { datasetAnimationScopeKeys(datasetType, transition) {
return cachedKeys(`${datasetType}.animation`, return cachedKeys(`${datasetType}.transition.${transition}`,
() => [ () => [
`datasets.${datasetType}.animation`, `datasets.${datasetType}.transitions.${transition}`,
`controllers.${datasetType}.animation`, `controllers.${datasetType}.transitions.${transition}`,
`controllers.${datasetType}.datasets.animation`, `controllers.${datasetType}.datasets.transitions.${transition}`,
'animation' `transitions.${transition}`,
`datasets.${datasetType}`,
`controllers.${datasetType}`,
`controllers.${datasetType}.datasets`,
''
]); ]);
} }

View File

@ -763,11 +763,11 @@ export default class DatasetController {
/** /**
* @private * @private
*/ */
_resolveAnimations(index, mode, active) { _resolveAnimations(index, transition, active) {
const me = this; const me = this;
const chart = me.chart; const chart = me.chart;
const cache = me._cachedDataOpts; const cache = me._cachedDataOpts;
const cacheKey = 'animation-' + mode; const cacheKey = `animation-${transition}`;
const cached = cache[cacheKey]; const cached = cache[cacheKey];
if (cached) { if (cached) {
return cached; return cached;
@ -775,11 +775,11 @@ export default class DatasetController {
let options; let options;
if (chart.options.animation !== false) { if (chart.options.animation !== false) {
const config = me.chart.config; const config = me.chart.config;
const scopeKeys = config.datasetAnimationScopeKeys(me._type); const scopeKeys = config.datasetAnimationScopeKeys(me._type, transition);
const scopes = config.getOptionScopes(me.getDataset().animation, scopeKeys); const scopes = config.getOptionScopes(me.getDataset(), scopeKeys);
options = config.createResolver(scopes, me.getContext(index, active, mode)); 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) { if (options && options._cacheable) {
cache[cacheKey] = Object.freeze(animations); cache[cacheKey] = Object.freeze(animations);
} }

View File

@ -83,12 +83,16 @@ defaults.route('scale.ticks', 'color', '', 'color');
defaults.route('scale.gridLines', 'color', '', 'borderColor'); defaults.route('scale.gridLines', 'color', '', 'borderColor');
defaults.route('scale.scaleLabel', 'color', '', 'color'); defaults.route('scale.scaleLabel', 'color', '', 'color');
defaults.describe('scales', { defaults.describe('scale', {
_fallback: 'scale', _fallback: false,
_scriptable: (name) => !name.startsWith('before') && !name.startsWith('after') && name !== 'callback' && name !== 'parser', _scriptable: (name) => !name.startsWith('before') && !name.startsWith('after') && name !== 'callback' && name !== 'parser',
_indexable: (name) => name !== 'borderDash' && name !== 'tickBorderDash', _indexable: (name) => name !== 'borderDash' && name !== 'tickBorderDash',
}); });
defaults.describe('scales', {
_fallback: 'scale',
});
/** /**
* Returns a new array containing numItems from arr * Returns a new array containing numItems from arr
* @param {any[]} arr * @param {any[]} arr

View File

@ -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. * Creates a Proxy for resolving raw values for options.
* @param {object[]} scopes - The option scopes to look for values, in resolution order * @param {object[]} scopes - The option scopes to look for values, in resolution order
* @param {string[]} [prefixes] - The prefixes 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 * @returns Proxy
* @private * @private
*/ */
export function _createResolver(scopes, prefixes = ['']) { export function _createResolver(scopes, prefixes = [''], rootScopes = scopes, fallback) {
if (!defined(fallback)) {
fallback = _resolve('_fallback', scopes);
}
const cache = { const cache = {
[Symbol.toStringTag]: 'Object', [Symbol.toStringTag]: 'Object',
_cacheable: true, _cacheable: true,
_scopes: scopes, _scopes: scopes,
override: (scope) => _createResolver([scope, ...scopes], prefixes), _rootScopes: rootScopes,
_fallback: fallback,
override: (scope) => _createResolver([scope, ...scopes], prefixes, rootScopes, fallback),
}; };
return new Proxy(cache, { return new Proxy(cache, {
/** /**
@ -20,7 +27,7 @@ export function _createResolver(scopes, prefixes = ['']) {
*/ */
get(target, prop) { get(target, prop) {
return _cached(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); _stack.delete(prop);
if (isObject(value)) { if (isObject(value)) {
// When scriptable option returns an object, create a resolver on that. // 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; return value;
} }
@ -202,64 +209,69 @@ function _resolveArray(prop, value, target, isIndexable) {
const scopes = _proxy._scopes.filter(s => s !== arr); const scopes = _proxy._scopes.filter(s => s !== arr);
value = []; value = [];
for (const item of arr) { 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])); value.push(_attachContext(resolver, _context, _subProxy && _subProxy[prop]));
} }
} }
return value; return value;
} }
function createSubResolver(parentScopes, prop, value) { function resolveFallback(fallback, prop, value) {
const set = new Set([value]); return isFunction(fallback) ? fallback(prop, value) : fallback;
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));
} }
} else if (key !== prop && scope === false) { const getScope = (key, parent) => key === true ? parent : resolveObjectKey(parent, key);
// If any of the fallback scopes is explicitly false, return false
// For example, options.hover falls back to options.interaction, when function addScopes(set, parentScopes, key, parentFallback) {
// options.interaction is false, options.hover will also resolve as false. 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;
}
}
return false;
}
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 false;
} }
} }
if (defined(fallback) && fallback !== prop) {
const fallbackScopes = allScopes;
key = fallback;
while (key !== false) {
key = addScopes(set, fallbackScopes, key, fallback);
} }
if (includeParents) {
parentScopes.forEach(set.add, set);
} }
return _createResolver([...set]); return _createResolver([...set], [''], rootScopes, fallback);
} }
function resolveFallback(fallback, prop, value) {
const resolved = isFunction(fallback) ? fallback(prop, value) : fallback;
return isArray(resolved) ? resolved : typeof resolved === 'string' ? [resolved] : [];
}
function _resolveSubKeys(parentScopes, prop, value) { function _resolveWithPrefixes(prop, prefixes, scopes, proxy) {
const fallback = valueOrDefault(_resolve('_fallback', parentScopes.map(scope => scope[prop] || scope)), true);
const keys = [prop];
if (defined(fallback)) {
keys.push(...resolveFallback(fallback, prop, value));
}
return {keys: keys.filter(v => v), includeParents: fallback !== false && fallback !== prop};
}
function _resolveWithPrefixes(prop, prefixes, scopes) {
let value; let value;
for (const prefix of prefixes) { for (const prefix of prefixes) {
value = _resolve(readKey(prefix, prop), scopes); value = _resolve(readKey(prefix, prop), scopes);
if (defined(value)) { if (defined(value)) {
return (needsSubResolver(prop, value)) return needsSubResolver(prop, value)
? createSubResolver(scopes, prop, value) ? createSubResolver(scopes, proxy, prop, value)
: value; : value;
} }
} }

View File

@ -385,9 +385,11 @@ export class Tooltip extends Element {
const chart = me._chart; const chart = me._chart;
const options = me.options; 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); const animations = new Animations(me._chart, opts);
if (opts._cacheable) {
me._cachedAnimations = Object.freeze(animations); me._cachedAnimations = Object.freeze(animations);
}
return animations; return animations;
} }
@ -1108,6 +1110,8 @@ export default {
animation: { animation: {
duration: 400, duration: 400,
easing: 'easeOutQuart', easing: 'easeOutQuart',
},
animations: {
numbers: { numbers: {
type: 'number', type: 'number',
properties: ['x', 'y', 'width', 'height', 'caretX', 'caretY'], properties: ['x', 'y', 'width', 'height', 'caretX', 'caretY'],
@ -1203,6 +1207,12 @@ export default {
callbacks: { callbacks: {
_scriptable: false, _scriptable: false,
_indexable: false, _indexable: false,
},
animation: {
_fallback: false
},
animations: {
_fallback: 'animation'
} }
}, },

View File

@ -742,6 +742,18 @@ describe('Chart.DatasetController', function() {
}); });
describe('_resolveAnimations', 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() { it('should resolve to empty Animations when globally disabled', function() {
const chart = acquireChart({ const chart = acquireChart({
type: 'line', type: 'line',
@ -778,5 +790,70 @@ describe('Chart.DatasetController', function() {
expect(controller._resolveAnimations(0)._properties.size).toEqual(0); 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
}
});
});
}); });
}); });

View File

@ -17,7 +17,10 @@ describe('Chart.helpers.config', function() {
expect(resolver.hoverColor).toEqual(defaults.hoverColor); 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 = { const defaults = {
root: true, root: true,
sub: { sub: {
@ -28,7 +31,7 @@ describe('Chart.helpers.config', function() {
child: 'sub default comes before this', child: 'sub default comes before this',
opt: 'opt' opt: 'opt'
}; };
const resolver = _createResolver([options, defaults]); const resolver = _createResolver([options, defaults, descriptors]);
const sub = resolver.sub; const sub = resolver.sub;
expect(sub.root).toEqual(true); expect(sub.root).toEqual(true);
expect(sub.child).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 = { const defaults = {
hover: { hover: {
_fallback: false,
a: 'defaults.hover' a: 'defaults.hover'
}, },
controllers: { controllers: {
@ -252,16 +254,23 @@ describe('Chart.helpers.config', function() {
}); });
it('should fallback throuhg multiple routes', function() { it('should fallback throuhg multiple routes', function() {
const descriptors = {
_fallback: 'level1',
level1: {
_fallback: 'root'
},
level2: {
_fallback: 'level1'
}
};
const defaults = { const defaults = {
root: { root: {
a: 'root' a: 'root'
}, },
level1: { level1: {
_fallback: 'root',
b: 'level1', b: 'level1',
}, },
level2: { level2: {
_fallback: 'level1',
level1: { level1: {
g: 'level2.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({ expect(resolver.level1).toEqualOptions({
a: 'root', a: 'root',
b: 'level1', b: 'level1',
@ -292,7 +301,7 @@ describe('Chart.helpers.config', function() {
expect(resolver.level2.sublevel1).toEqualOptions({ expect(resolver.level2.sublevel1).toEqualOptions({
a: 'root', a: 'root',
b: 'level1', b: 'level1',
c: 'level2', // TODO: this should be undefined c: undefined,
d: 'sublevel1', d: 'sublevel1',
e: undefined, e: undefined,
f: undefined, f: undefined,
@ -301,7 +310,7 @@ describe('Chart.helpers.config', function() {
expect(resolver.level2.sublevel2).toEqualOptions({ expect(resolver.level2.sublevel2).toEqualOptions({
a: 'root', a: 'root',
b: 'level1', b: 'level1',
c: 'level2', // TODO: this should be undefined c: undefined,
d: undefined, d: undefined,
e: 'sublevel2', e: 'sublevel2',
f: undefined, f: undefined,
@ -310,13 +319,129 @@ describe('Chart.helpers.config', function() {
expect(resolver.level2.sublevel2.level1).toEqualOptions({ expect(resolver.level2.sublevel2.level1).toEqualOptions({
a: 'root', a: 'root',
b: 'level1', b: 'level1',
c: 'level2', // TODO: this should be undefined c: undefined,
d: undefined, d: undefined,
e: 'sublevel2', // TODO: this should be undefined e: undefined,
f: 'sublevel2.level1', 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',
]);
});
}); });
}); });

54
types/index.esm.d.ts vendored
View File

@ -1335,12 +1335,9 @@ export interface HoverInteractionOptions extends CoreInteractionOptions {
onHover(event: ChartEvent, elements: ActiveElement[], chart: Chart): void; onHover(event: ChartEvent, elements: ActiveElement[], chart: Chart): void;
} }
export interface CoreChartOptions extends ParsingOptions { export interface CoreChartOptions extends ParsingOptions, AnimationOptions {
animation: Scriptable<AnimationOptions | false, ScriptableContext>;
datasets: { datasets: AnimationOptions;
animation: Scriptable<AnimationOptions | false, ScriptableContext>;
};
/** /**
* The base axis of the chart. 'x' for vertical charts and 'y' for horizontal charts. * The base axis of the chart. 'x' for vertical charts and 'y' for horizontal charts.
@ -1460,38 +1457,39 @@ export type EasingFunction =
| 'easeOutBounce' | 'easeOutBounce'
| 'easeInOutBounce'; | 'easeInOutBounce';
export interface AnimationCommonSpec { export type AnimationSpec = {
/** /**
* The number of milliseconds an animation takes. * The number of milliseconds an animation takes.
* @default 1000 * @default 1000
*/ */
duration: number; duration: Scriptable<number, ScriptableContext>;
/** /**
* Easing function to use * Easing function to use
* @default 'easeOutQuart' * @default 'easeOutQuart'
*/ */
easing: EasingFunction; easing: Scriptable<EasingFunction, ScriptableContext>;
/** /**
* Running animation count + FPS display in upper left corner of the chart. * Running animation count + FPS display in upper left corner of the chart.
* @default false * @default false
*/ */
debug: boolean; debug: Scriptable<boolean, ScriptableContext>;
/** /**
* Delay before starting the animations. * Delay before starting the animations.
* @default 0 * @default 0
*/ */
delay: number; delay: Scriptable<number, ScriptableContext>;
/** /**
* If set to true, the animations loop endlessly. * If set to true, the animations loop endlessly.
* @default false * @default false
*/ */
loop: boolean; loop: Scriptable<boolean, ScriptableContext>;
} }
export interface AnimationPropertySpec extends AnimationCommonSpec { export type AnimationsSpec = {
[name: string]: AnimationSpec & {
properties: string[]; properties: string[];
/** /**
@ -1504,18 +1502,25 @@ export interface AnimationPropertySpec extends AnimationCommonSpec {
/** /**
* Start value for the animation. Current value is used when undefined * Start value for the animation. Current value is used when undefined
*/ */
from: Color | number | boolean; from: Scriptable<Color | number | boolean, ScriptableContext>;
/** /**
* *
*/ */
to: Color | number | boolean; to: Scriptable<Color | number | boolean, ScriptableContext>;
} | false
} }
export type AnimationSpecContainer = AnimationCommonSpec & { export type TransitionSpec = {
[prop: string]: AnimationPropertySpec | false; animation: AnimationSpec;
}; animations: AnimationsSpec;
}
export type AnimationOptions = AnimationSpecContainer & { export type TransitionsSpec = {
[mode: string]: TransitionSpec
}
export type AnimationOptions = {
animation: AnimationSpec & {
/** /**
* Callback called on each step of an animation. * Callback called on each step of an animation.
*/ */
@ -1524,12 +1529,9 @@ export type AnimationOptions = AnimationSpecContainer & {
* Callback called when all animations are completed. * Callback called when all animations are completed.
*/ */
onComplete: (this: Chart, event: AnimationEvent) => void; onComplete: (this: Chart, event: AnimationEvent) => void;
};
active: AnimationSpecContainer | false; animations: AnimationsSpec;
hide: AnimationSpecContainer | false; transitions: TransitionsSpec;
reset: AnimationSpecContainer | false;
resize: AnimationSpecContainer | false;
show: AnimationSpecContainer | false;
}; };
export interface FontSpec { export interface FontSpec {
@ -2452,7 +2454,9 @@ export interface TooltipOptions extends CoreInteractionOptions {
*/ */
textDirection: string; textDirection: string;
animation: Scriptable<AnimationSpecContainer, ScriptableContext>; animation: AnimationSpec;
animations: AnimationsSpec;
callbacks: TooltipCallbacks; callbacks: TooltipCallbacks;
} }