From 2d7e0ccc58ddd46b2953a686ff2630afbac5598a Mon Sep 17 00:00:00 2001 From: Jukka Kurkela Date: Thu, 31 Dec 2020 17:29:05 +0200 Subject: [PATCH 1/4] Option resolution with proxies --- docs/docs/configuration/index.md | 24 +- docs/docs/general/options.md | 71 +++++ docs/docs/getting-started/v3-migration.md | 4 + samples/animations/delay.html | 30 +- samples/animations/loop.html | 15 +- src/controllers/controller.bar.js | 31 +- src/controllers/controller.bubble.js | 27 +- src/controllers/controller.doughnut.js | 33 +- src/controllers/controller.line.js | 69 +---- src/controllers/controller.pie.js | 11 +- src/controllers/controller.polarArea.js | 42 +-- src/controllers/controller.radar.js | 71 +---- src/core/core.animations.js | 64 ++-- src/core/core.config.js | 189 +++++++---- src/core/core.controller.js | 27 +- src/core/core.datasetController.js | 216 ++++--------- src/core/core.defaults.js | 47 ++- src/core/core.layouts.js | 7 +- src/core/core.plugins.js | 29 +- src/core/core.scale.js | 65 ++-- src/core/core.typedRegistry.js | 4 + src/elements/element.arc.js | 3 +- src/elements/element.bar.js | 3 +- src/elements/element.line.js | 11 +- src/elements/element.point.js | 3 +- src/helpers/helpers.config.js | 230 ++++++++++++++ src/helpers/helpers.core.js | 5 + src/helpers/index.js | 1 + src/platform/platform.base.js | 4 +- src/platform/platform.dom.js | 12 +- src/plugins/plugin.legend.js | 7 + src/plugins/plugin.tooltip.js | 20 +- src/scales/scale.radialLinear.js | 72 ++--- .../gridlines-scriptable.js | 4 +- test/specs/controller.bar.tests.js | 2 +- test/specs/core.animations.tests.js | 5 +- test/specs/core.controller.tests.js | 9 +- test/specs/core.datasetController.tests.js | 79 ----- test/specs/core.plugin.tests.js | 24 +- test/specs/helpers.config.tests.js | 293 ++++++++++++++++++ test/specs/plugin.legend.tests.js | 14 +- test/specs/plugin.title.tests.js | 2 +- test/specs/plugin.tooltip.tests.js | 101 +++--- types/index.esm.d.ts | 4 +- 44 files changed, 1206 insertions(+), 778 deletions(-) create mode 100644 src/helpers/helpers.config.js create mode 100644 test/specs/helpers.config.tests.js diff --git a/docs/docs/configuration/index.md b/docs/docs/configuration/index.md index 7abba9e838d..ce8074f5c37 100644 --- a/docs/docs/configuration/index.md +++ b/docs/docs/configuration/index.md @@ -10,23 +10,23 @@ This concept was introduced in Chart.js 1.0 to keep configuration [DRY](https:// Chart.js merges the options object passed to the chart with the global configuration using chart type defaults and scales defaults appropriately. This way you can be as specific as you would like in your individual chart configuration, while still changing the defaults for all chart types where applicable. The global general options are defined in `Chart.defaults`. The defaults for each chart type are discussed in the documentation for that chart type. -The following example would set the hover mode to 'nearest' for all charts where this was not overridden by the chart type defaults or the options passed to the constructor on creation. +The following example would set the interaction mode to 'nearest' for all charts where this was not overridden by the chart type defaults or the options passed to the constructor on creation. ```javascript -Chart.defaults.hover.mode = 'nearest'; +Chart.defaults.interaction.mode = 'nearest'; -// Hover mode is set to nearest because it was not overridden here -var chartHoverModeNearest = new Chart(ctx, { +// Interaction mode is set to nearest because it was not overridden here +var chartInteractionModeNearest = new Chart(ctx, { type: 'line', data: data }); -// This chart would have the hover mode that was passed in -var chartDifferentHoverMode = new Chart(ctx, { +// This chart would have the interaction mode that was passed in +var chartDifferentInteractionMode = new Chart(ctx, { type: 'line', data: data, options: { - hover: { + interaction: { // Overrides the global setting mode: 'index' } @@ -36,15 +36,7 @@ var chartDifferentHoverMode = new Chart(ctx, { ## Dataset Configuration -Options may be configured directly on the dataset. The dataset options can be changed at 3 different levels and are evaluated with the following priority: - -- per dataset: dataset.* -- per chart: options.datasets[type].* -- or globally: Chart.defaults.controllers[type].datasets.* - -where type corresponds to the dataset type. - -*Note:* dataset options take precedence over element options. +Options may be configured directly on the dataset. The dataset options can be changed at multiple different levels. See [options](../general/options.md#dataset-level-options) for details on how the options are resolved. The following example would set the `showLine` option to 'false' for all line datasets except for those overridden by options passed to the dataset on creation. diff --git a/docs/docs/general/options.md b/docs/docs/general/options.md index 77761385322..a117a72cc4d 100644 --- a/docs/docs/general/options.md +++ b/docs/docs/general/options.md @@ -2,9 +2,74 @@ title: Options --- +## Option resolution + +Options are resolved from top to bottom, using a context dependent route. + +### Chart level options + +* options +* defaults.controllers[`config.type`] +* defaults + +### Dataset level options + +`dataset.type` defaults to `config.type`, if not specified. + +* dataset +* options.datasets[`dataset.type`] +* options.controllers[`dataset.type`].datasets +* options +* defaults.datasets[`dataset.type`] +* defaults.controllers[`dataset.type`].datasets +* defaults + +### Dataset animation options + +* dataset.animation +* options.controllers[`dataset.type`].datasets.animation +* options.animation +* defaults.controllers[`dataset.type`].datasets.animation +* defaults.animation + +### Dataset element level options + +Each scope is looked up with `elementType` prefix in the option name first, then wihtout the prefix. For example, `radius` for `point` element is looked up using `pointRadius` and if that does not hit, then `radius`. + +* dataset +* options.datasets[`dataset.type`] +* options.controllers[`dataset.type`].datasets +* options.controllers[`dataset.type`].elements[`elementType`] +* options.elements[`elementType`] +* options +* defaults.datasets[`dataset.type`] +* defaults.controllers[`dataset.type`].datasets +* defaults.controllers[`dataset.type`].elements[`elementType`] +* defaults.elements[`elementType`] +* defaults + +### Scale options + +* options.scales +* defaults.controllers[`config.type`].scales +* defaults.controllers[`dataset.type`].scales +* defaults.scales + +### Plugin options + +* options.plugins[`plugin.id`] +* options.controllers[`config.type`].plugins[`plugin.id`] +* (options.[`...plugin.additionalOptionScopes`]) +* options +* defaults.controllers[`config.type`].plugins[`plugin.id`] +* defaults.plugins[`plugin.id`] +* (defaults.[`...plugin.additionalOptionScopes`]) +* defaults + ## Scriptable Options Scriptable options also accept a function which is called for each of the underlying data values and that takes the unique argument `context` representing contextual information (see [option context](options.md#option-context)). +A resolver is passed as second parameter, that can be used to access other options in the same context. Example: @@ -15,6 +80,10 @@ color: function(context) { return value < 0 ? 'red' : // draw negative values in red index % 2 ? 'blue' : // else, alternate values in blue and green 'green'; +}, +borderColor: function(context, options) { + var color = options.color; // resolve the value of another scriptable option: 'red', 'blue' or 'green' + return Chart.helpers.color(color).lighten(0.2); } ``` @@ -64,6 +133,7 @@ In addition to [chart](#chart) * `dataset`: dataset at index `datasetIndex` * `datasetIndex`: index of the current dataset * `index`: getter for `datasetIndex` +* `mode`: the update mode * `type`: `'dataset'` ### data @@ -76,6 +146,7 @@ In addition to [dataset](#dataset) * `raw`: the raw data values for the given `dataIndex` and `datasetIndex` * `element`: the element (point, arc, bar, etc.) for this data * `index`: getter for `dataIndex` +* `mode`: the update mode * `type`: `'data'` ### scale diff --git a/docs/docs/getting-started/v3-migration.md b/docs/docs/getting-started/v3-migration.md index 6b86132ecf2..f028bf09775 100644 --- a/docs/docs/getting-started/v3-migration.md +++ b/docs/docs/getting-started/v3-migration.md @@ -63,6 +63,10 @@ A number of changes were made to the configuration options passed to the `Chart` * Indexable options are now looping. `backgroundColor: ['red', 'green']` will result in alternating `'red'` / `'green'` if there are more than 2 data points. * The input properties of object data can now be freely specified, see [data structures](../general/data-structures.md) for details. +* Most options are resolved utilizing proxies, instead merging with defaults. In addition to easily enabling different resolution routes for different contexts, it allows using other resolved options in scriptable options. + * Options are by default scriptable and indexable, unless disabled for some reason. + * Scriptable options receive a option reolver as second parameter for accessing other options in same context. + * Resolution falls to upper scopes, if no match is found earlier. See [options](./general/options.md) for details. #### Specific changes diff --git a/samples/animations/delay.html b/samples/animations/delay.html index 66b37a1236f..aa81bd73bb7 100644 --- a/samples/animations/delay.html +++ b/samples/animations/delay.html @@ -62,29 +62,23 @@ }; window.onload = function() { + var delayed = false; var ctx = document.getElementById('canvas').getContext('2d'); window.myBar = new Chart(ctx, { type: 'bar', data: barChartData, options: { - animation: (context) => { - if (context.active) { - return { - duration: 400 - }; - } - var delay = 0; - var dsIndex = context.datasetIndex; - var index = context.dataIndex; - if (context.parsed && !context.delayed) { - delay = index * 300 + dsIndex * 100; - context.delayed = true; - } - return { - easing: 'linear', - duration: 600, - delay - }; + animation: { + onComplete: () => { + delayed = true; + }, + delay: (context) => { + let delay = 0; + if (context.type === 'data' && context.mode === 'default' && !delayed) { + delay = context.dataIndex * 300 + context.datasetIndex * 100; + } + return delay; + }, }, plugins: { title: { diff --git a/samples/animations/loop.html b/samples/animations/loop.html index 81956b56d40..9356a3f3a63 100644 --- a/samples/animations/loop.html +++ b/samples/animations/loop.html @@ -62,16 +62,13 @@ }] }, options: { - animation: (context) => Object.assign({}, - Chart.defaults.animation, - { - radius: { - duration: 400, - easing: 'linear', - loop: context.active - } + animation: { + radius: { + duration: 400, + easing: 'linear', + loop: (context) => context.active } - ), + }, elements: { point: { hoverRadius: 6 diff --git a/src/controllers/controller.bar.js b/src/controllers/controller.bar.js index 165421209d1..1dfcc44df6c 100644 --- a/src/controllers/controller.bar.js +++ b/src/controllers/controller.bar.js @@ -266,9 +266,8 @@ export default class BarController extends DatasetController { me.updateSharedOptions(sharedOptions, mode, firstOpts); for (let i = start; i < start + count; i++) { - const options = sharedOptions || me.resolveDataElementOptions(i, mode); - const vpixels = me._calculateBarValuePixels(i, options); - const ipixels = me._calculateBarIndexPixels(i, ruler, options); + const vpixels = me._calculateBarValuePixels(i); + const ipixels = me._calculateBarIndexPixels(i, ruler); const properties = { horizontal, @@ -280,7 +279,7 @@ export default class BarController extends DatasetController { }; if (includeOptions) { - properties.options = options; + properties.options = sharedOptions || me.resolveDataElementOptions(i, mode); } me.updateElement(bars[i], i, properties, mode); } @@ -400,11 +399,11 @@ export default class BarController extends DatasetController { * Note: pixel values are not clamped to the scale area. * @private */ - _calculateBarValuePixels(index, options) { + _calculateBarValuePixels(index) { const me = this; const meta = me._cachedMeta; const vScale = meta.vScale; - const {base: baseValue, minBarLength} = options; + const {base: baseValue, minBarLength} = me.options; const parsed = me.getParsed(index); const custom = parsed._custom; const floating = isFloatBar(custom); @@ -459,9 +458,10 @@ export default class BarController extends DatasetController { /** * @private */ - _calculateBarIndexPixels(index, ruler, options) { + _calculateBarIndexPixels(index, ruler) { const me = this; - const stackCount = me.chart.options.skipNull ? me._getStackCount(index) : ruler.stackCount; + const options = me.options; + const stackCount = options.skipNull ? me._getStackCount(index) : ruler.stackCount; const range = options.barThickness === 'flex' ? computeFlexCategoryTraits(index, ruler, options, stackCount) : computeFitCategoryTraits(index, ruler, options, stackCount); @@ -510,20 +510,7 @@ BarController.id = 'bar'; BarController.defaults = { datasetElementType: false, dataElementType: 'bar', - dataElementOptions: [ - 'backgroundColor', - 'borderColor', - 'borderSkipped', - 'borderWidth', - 'borderRadius', - 'barPercentage', - 'barThickness', - 'base', - 'categoryPercentage', - 'maxBarThickness', - 'minBarLength', - 'pointStyle' - ], + interaction: { mode: 'index' }, diff --git a/src/controllers/controller.bubble.js b/src/controllers/controller.bubble.js index 7c8c7dcd7af..f1f0e1ecfa0 100644 --- a/src/controllers/controller.bubble.js +++ b/src/controllers/controller.bubble.js @@ -1,6 +1,5 @@ import DatasetController from '../core/core.datasetController'; -import {resolve} from '../helpers/helpers.options'; -import {resolveObjectKey} from '../helpers/helpers.core'; +import {resolveObjectKey, valueOrDefault} from '../helpers/helpers.core'; export default class BubbleController extends DatasetController { initialize() { @@ -107,29 +106,20 @@ export default class BubbleController extends DatasetController { * @protected */ resolveDataElementOptions(index, mode) { - const me = this; - const chart = me.chart; - const parsed = me.getParsed(index); + const parsed = this.getParsed(index); let values = super.resolveDataElementOptions(index, mode); - // Scriptable options - const context = me.getContext(index, mode === 'active'); - // In case values were cached (and thus frozen), we need to clone the values if (values.$shared) { values = Object.assign({}, values, {$shared: false}); } - // Custom radius resolution + const radius = values.radius; if (mode !== 'active') { values.radius = 0; } - values.radius += resolve([ - parsed && parsed._custom, - me._config.radius, - chart.options.elements.point.radius - ], context, index); + values.radius += valueOrDefault(parsed && parsed._custom, radius); return values; } @@ -143,15 +133,6 @@ BubbleController.id = 'bubble'; BubbleController.defaults = { datasetElementType: false, dataElementType: 'point', - dataElementOptions: [ - 'backgroundColor', - 'borderColor', - 'borderWidth', - 'hitRadius', - 'radius', - 'pointStyle', - 'rotation' - ], animation: { numbers: { properties: ['x', 'y', 'borderWidth', 'radius'] diff --git a/src/controllers/controller.doughnut.js b/src/controllers/controller.doughnut.js index 24e242fa30e..e10fa42b97b 100644 --- a/src/controllers/controller.doughnut.js +++ b/src/controllers/controller.doughnut.js @@ -81,14 +81,14 @@ export default class DoughnutController extends DatasetController { * @private */ _getRotation() { - return toRadians(valueOrDefault(this._config.rotation, this.chart.options.rotation) - 90); + return toRadians(this.options.rotation - 90); } /** * @private */ _getCircumference() { - return toRadians(valueOrDefault(this._config.circumference, this.chart.options.circumference)); + return toRadians(this.options.circumference); } /** @@ -124,10 +124,10 @@ export default class DoughnutController extends DatasetController { update(mode) { const me = this; const chart = me.chart; - const {chartArea, options} = chart; + const {chartArea} = chart; const meta = me._cachedMeta; const arcs = meta.data; - const cutout = options.cutoutPercentage / 100 || 0; + const cutout = me.options.cutoutPercentage / 100 || 0; const chartWeight = me._getRingWeight(me.index); // Compute the maximal rotation & circumference limits. @@ -157,7 +157,7 @@ export default class DoughnutController extends DatasetController { */ _circumference(i, reset) { const me = this; - const opts = me.chart.options; + const opts = me.options; const meta = me._cachedMeta; const circumference = me._getCircumference(); return reset && opts.animation.animateRotate ? 0 : this.chart.getDataVisibility(i) ? me.calculateCircumference(meta._parsed[i] * circumference / TAU) : 0; @@ -328,13 +328,6 @@ DoughnutController.id = 'doughnut'; DoughnutController.defaults = { datasetElementType: false, dataElementType: 'arc', - dataElementOptions: [ - 'backgroundColor', - 'borderColor', - 'borderWidth', - 'borderAlign', - 'offset' - ], animation: { numbers: { type: 'number', @@ -347,14 +340,18 @@ DoughnutController.defaults = { }, aspectRatio: 1, - // The percentage of the chart that we cut out of the middle. - cutoutPercentage: 50, + datasets: { + // The percentage of the chart that we cut out of the middle. + cutoutPercentage: 50, - // The rotation of the chart, where the first data arc begins. - rotation: 0, + // The rotation of the chart, where the first data arc begins. + rotation: 0, - // The total circumference of the chart. - circumference: 360, + // The total circumference of the chart. + circumference: 360 + }, + + indexAxis: 'r', // Need to override these to give a nice default plugins: { diff --git a/src/controllers/controller.line.js b/src/controllers/controller.line.js index ed3f9319ed1..9cbd48aa63f 100644 --- a/src/controllers/controller.line.js +++ b/src/controllers/controller.line.js @@ -1,7 +1,5 @@ import DatasetController from '../core/core.datasetController'; -import {valueOrDefault} from '../helpers/helpers.core'; import {isNumber, _limitValue} from '../helpers/helpers.math'; -import {resolve} from '../helpers/helpers.options'; import {_lookupByKey} from '../helpers/helpers.collection'; export default class LineController extends DatasetController { @@ -32,9 +30,13 @@ export default class LineController extends DatasetController { // In resize mode only point locations change, so no need to set the options. if (mode !== 'resize') { + const options = me.resolveDatasetElementOptions(mode); + if (!me.options.showLine) { + options.borderWidth = 0; + } me.updateElement(line, undefined, { animated: !animationsDisabled, - options: me.resolveDatasetElementOptions() + options }, mode); } @@ -49,7 +51,7 @@ export default class LineController extends DatasetController { const firstOpts = me.resolveDataElementOptions(start, mode); const sharedOptions = me.getSharedOptions(firstOpts); const includeOptions = me.includeOptions(mode, sharedOptions); - const spanGaps = valueOrDefault(me._config.spanGaps, me.chart.options.spanGaps); + const spanGaps = me.options.spanGaps; const maxGapLength = isNumber(spanGaps) ? spanGaps : Number.POSITIVE_INFINITY; const directUpdate = me.chart._animationsDisabled || reset || mode === 'none'; let prevParsed = start > 0 && me.getParsed(start - 1); @@ -77,32 +79,6 @@ export default class LineController extends DatasetController { me.updateSharedOptions(sharedOptions, mode, firstOpts); } - /** - * @param {boolean} [active] - * @protected - */ - resolveDatasetElementOptions(active) { - const me = this; - const config = me._config; - const options = me.chart.options; - const lineOptions = options.elements.line; - const values = super.resolveDatasetElementOptions(active); - const showLine = valueOrDefault(config.showLine, options.showLine); - - // The default behavior of lines is to break at null values, according - // to https://github.com/chartjs/Chart.js/issues/2435#issuecomment-216718158 - // This option gives lines the ability to span gaps - values.spanGaps = valueOrDefault(config.spanGaps, options.spanGaps); - values.tension = valueOrDefault(config.tension, lineOptions.tension); - values.stepped = resolve([config.stepped, lineOptions.stepped]); - - if (!showLine) { - values.borderWidth = 0; - } - - return values; - } - /** * @protected */ @@ -132,37 +108,12 @@ LineController.id = 'line'; */ LineController.defaults = { datasetElementType: 'line', - datasetElementOptions: [ - 'backgroundColor', - 'borderCapStyle', - 'borderColor', - 'borderDash', - 'borderDashOffset', - 'borderJoinStyle', - 'borderWidth', - 'capBezierPoints', - 'cubicInterpolationMode', - 'fill' - ], - dataElementType: 'point', - dataElementOptions: { - backgroundColor: 'pointBackgroundColor', - borderColor: 'pointBorderColor', - borderWidth: 'pointBorderWidth', - hitRadius: 'pointHitRadius', - hoverHitRadius: 'pointHitRadius', - hoverBackgroundColor: 'pointHoverBackgroundColor', - hoverBorderColor: 'pointHoverBorderColor', - hoverBorderWidth: 'pointHoverBorderWidth', - hoverRadius: 'pointHoverRadius', - pointStyle: 'pointStyle', - radius: 'pointRadius', - rotation: 'pointRotation' - }, - showLine: true, - spanGaps: false, + datasets: { + showLine: true, + spanGaps: false, + }, interaction: { mode: 'index' diff --git a/src/controllers/controller.pie.js b/src/controllers/controller.pie.js index 158590a18f0..c88a3cbc343 100644 --- a/src/controllers/controller.pie.js +++ b/src/controllers/controller.pie.js @@ -11,5 +11,14 @@ PieController.id = 'pie'; * @type {any} */ PieController.defaults = { - cutoutPercentage: 0 + datasets: { + // The percentage of the chart that we cut out of the middle. + cutoutPercentage: 0, + + // The rotation of the chart, where the first data arc begins. + rotation: 0, + + // The total circumference of the chart. + circumference: 360 + } }; diff --git a/src/controllers/controller.polarArea.js b/src/controllers/controller.polarArea.js index 0ce190395e2..a476c275a24 100644 --- a/src/controllers/controller.polarArea.js +++ b/src/controllers/controller.polarArea.js @@ -1,5 +1,5 @@ import DatasetController from '../core/core.datasetController'; -import {resolve, toRadians, PI} from '../helpers/index'; +import {toRadians, PI} from '../helpers/index'; function getStartAngleRadians(deg) { // radialLinear scale draws angleLines using startAngle. 0 is expected to be at top. @@ -55,16 +55,16 @@ export default class PolarAreaController extends DatasetController { let angle = datasetStartAngle; let i; - me._cachedMeta.count = me.countVisibleElements(); + const defaultAngle = 360 / me.countVisibleElements(); for (i = 0; i < start; ++i) { - angle += me._computeAngle(i, mode); + angle += me._computeAngle(i, mode, defaultAngle); } for (i = start; i < start + count; i++) { const arc = arcs[i]; let startAngle = angle; - let endAngle = angle + me._computeAngle(i, mode); - let outerRadius = this.chart.getDataVisibility(i) ? scale.getDistanceFromCenterForValue(dataset.data[i]) : 0; + let endAngle = angle + me._computeAngle(i, mode, defaultAngle); + let outerRadius = chart.getDataVisibility(i) ? scale.getDistanceFromCenterForValue(dataset.data[i]) : 0; angle = endAngle; if (reset) { @@ -72,8 +72,7 @@ export default class PolarAreaController extends DatasetController { outerRadius = 0; } if (animationOpts.animateRotate) { - startAngle = datasetStartAngle; - endAngle = datasetStartAngle; + startAngle = endAngle = datasetStartAngle; } } @@ -108,23 +107,10 @@ export default class PolarAreaController extends DatasetController { /** * @private */ - _computeAngle(index, mode) { - const me = this; - const meta = me._cachedMeta; - const count = meta.count; - const dataset = me.getDataset(); - - if (isNaN(dataset.data[index]) || !this.chart.getDataVisibility(index)) { - return 0; - } - - // Scriptable options - const context = me.getContext(index, mode === 'active'); - - return toRadians(resolve([ - me.chart.options.elements.arc.angle, - 360 / count - ], context, index)); + _computeAngle(index, mode, defaultAngle) { + return this.chart.getDataVisibility(index) + ? toRadians(this.resolveDataElementOptions(index, mode).angle || defaultAngle) + : 0; } } @@ -135,14 +121,6 @@ PolarAreaController.id = 'polarArea'; */ PolarAreaController.defaults = { dataElementType: 'arc', - dataElementOptions: [ - 'backgroundColor', - 'borderColor', - 'borderWidth', - 'borderAlign', - 'offset' - ], - animation: { numbers: { type: 'number', diff --git a/src/controllers/controller.radar.js b/src/controllers/controller.radar.js index a2b8a2b1260..36473f60079 100644 --- a/src/controllers/controller.radar.js +++ b/src/controllers/controller.radar.js @@ -1,5 +1,4 @@ import DatasetController from '../core/core.datasetController'; -import {valueOrDefault} from '../helpers/helpers.core'; export default class RadarController extends DatasetController { @@ -28,10 +27,15 @@ export default class RadarController extends DatasetController { line.points = points; // In resize mode only point locations change, so no need to set the points or options. if (mode !== 'resize') { + const options = me.resolveDatasetElementOptions(mode); + if (!me.options.showLine) { + options.borderWidth = 0; + } + const properties = { _loop: true, _fullLoop: labels.length === points.length, - options: me.resolveDatasetElementOptions() + options }; me.updateElement(line, undefined, properties, mode); @@ -66,27 +70,6 @@ export default class RadarController extends DatasetController { me.updateElement(point, i, properties, mode); } } - - /** - * @param {boolean} [active] - * @protected - */ - resolveDatasetElementOptions(active) { - const me = this; - const config = me._config; - const options = me.chart.options; - const values = super.resolveDatasetElementOptions(active); - const showLine = valueOrDefault(config.showLine, options.showLine); - - values.spanGaps = valueOrDefault(config.spanGaps, options.spanGaps); - values.tension = valueOrDefault(config.tension, options.elements.line.tension); - - if (!showLine) { - values.borderWidth = 0; - } - - return values; - } } RadarController.id = 'radar'; @@ -96,44 +79,20 @@ RadarController.id = 'radar'; */ RadarController.defaults = { datasetElementType: 'line', - datasetElementOptions: [ - 'backgroundColor', - 'borderColor', - 'borderCapStyle', - 'borderDash', - 'borderDashOffset', - 'borderJoinStyle', - 'borderWidth', - 'fill' - ], - dataElementType: 'point', - dataElementOptions: { - backgroundColor: 'pointBackgroundColor', - borderColor: 'pointBorderColor', - borderWidth: 'pointBorderWidth', - hitRadius: 'pointHitRadius', - hoverBackgroundColor: 'pointHoverBackgroundColor', - hoverBorderColor: 'pointHoverBorderColor', - hoverBorderWidth: 'pointHoverBorderWidth', - hoverRadius: 'pointHoverRadius', - pointStyle: 'pointStyle', - radius: 'pointRadius', - rotation: 'pointRotation' - }, - aspectRatio: 1, - spanGaps: false, - scales: { - r: { - type: 'radialLinear', - } + datasets: { + showLine: true, }, - indexAxis: 'r', elements: { line: { - fill: 'start', - tension: 0 // no bezier in radar + fill: 'start' + } + }, + indexAxis: 'r', + scales: { + r: { + type: 'radialLinear', } } }; diff --git a/src/core/core.animations.js b/src/core/core.animations.js index 285592db105..1fc7a0e0d20 100644 --- a/src/core/core.animations.js +++ b/src/core/core.animations.js @@ -1,17 +1,18 @@ import animator from './core.animator'; import Animation from './core.animation'; import defaults from './core.defaults'; -import {noop, isObject} from '../helpers/helpers.core'; +import {isObject} from '../helpers/helpers.core'; const numbers = ['x', 'y', 'borderWidth', 'radius', 'tension']; const colors = ['borderColor', 'backgroundColor']; +const animationOptions = ['duration', 'easing', 'from', 'to', 'type', 'easing', 'loop', 'fn']; defaults.set('animation', { // Plain properties can be overridden in each object duration: 1000, easing: 'easeOutQuart', - onProgress: noop, - onComplete: noop, + onProgress: undefined, + onComplete: undefined, // Property sets colors: { @@ -54,30 +55,11 @@ defaults.set('animation', { } }); -function copyOptions(target, values) { - const oldOpts = target.options; - const newOpts = values.options; - if (!oldOpts || !newOpts) { - return; - } - if (oldOpts.$shared && !newOpts.$shared) { - target.options = Object.assign({}, oldOpts, newOpts, {$shared: false}); - } else { - Object.assign(oldOpts, newOpts); - } - delete values.options; -} - -function extensibleConfig(animations) { - const result = {}; - Object.keys(animations).forEach(key => { - const value = animations[key]; - if (!isObject(value)) { - result[key] = value; - } - }); - return result; -} +defaults.describe('animation', { + _scriptable: (name) => name !== 'onProgress' && name !== 'onComplete' && name !== 'fn', + _indexable: false, + _fallback: 'animation', +}); export default class Animations { constructor(chart, animations) { @@ -92,22 +74,20 @@ export default class Animations { } const animatedProps = this._properties; - const animDefaults = extensibleConfig(animations); - Object.keys(animations).forEach(key => { + Object.getOwnPropertyNames(animations).forEach(key => { const cfg = animations[key]; if (!isObject(cfg)) { return; } + const resolved = {}; + for (const option of animationOptions) { + resolved[option] = cfg[option]; + } + (cfg.properties || [key]).forEach((prop) => { - // Can have only one config per animation. - if (!animatedProps.has(prop)) { - animatedProps.set(prop, Object.assign({}, animDefaults, cfg)); - } else if (prop === key) { - // Single property targetting config wins over multi-targetting. - // eslint-disable-next-line no-unused-vars - const {properties, ...inherited} = animatedProps.get(prop); - animatedProps.set(prop, Object.assign({}, inherited, cfg)); + if (prop === key || !animatedProps.has(prop)) { + animatedProps.set(prop, resolved); } }); }); @@ -125,8 +105,8 @@ export default class Animations { } const animations = this._createAnimations(options, newOptions); - if (newOptions.$shared && !options.$shared) { - // Going from distinct options to shared options: + if (newOptions.$shared) { + // Going to shared options: // After all animations are done, assign the shared options object to the element // So any new updates to the shared options are observed awaitAll(target.options.$animations, newOptions).then(() => { @@ -195,10 +175,6 @@ export default class Animations { update(target, values) { if (this._properties.size === 0) { // Nothing is animated, just apply the new values. - // Options can be shared, need to account for that. - copyOptions(target, values); - // copyOptions removes the `options` from `values`, - // unless it can be directly assigned. Object.assign(target, values); return; } @@ -234,7 +210,7 @@ function resolveTargetOptions(target, newOptions) { target.options = newOptions; return; } - if (options.$shared && !newOptions.$shared) { + if (options.$shared) { // Going from shared options to distinct one: // Create new options object containing the old shared values and start updating that. target.options = options = Object.assign({}, options, {$shared: false, $animations: {}}); diff --git a/src/core/core.config.js b/src/core/core.config.js index 06fcbbefff9..a8798965ccc 100644 --- a/src/core/core.config.js +++ b/src/core/core.config.js @@ -1,5 +1,6 @@ import defaults from './core.defaults'; -import {mergeIf, merge, _merger} from '../helpers/helpers.core'; +import {mergeIf, resolveObjectKey, isArray, isFunction, valueOrDefault} from '../helpers/helpers.core'; +import {_attachContext, _createResolver, _descriptors} from '../helpers/helpers.config'; export function getIndexAxis(type, options) { const typeDefaults = defaults.controllers[type] || {}; @@ -79,59 +80,12 @@ function mergeScaleConfig(config, options) { return scales; } -/** - * Recursively merge the given config objects as the root options by handling - * default scale options for the `scales` and `scale` properties, then returns - * a deep copy of the result, thus doesn't alter inputs. - */ -function mergeConfig(...args/* config objects ... */) { - return merge(Object.create(null), args, { - merger(key, target, source, options) { - if (key !== 'scales' && key !== 'scale' && key !== 'controllers') { - _merger(key, target, source, options); - } - } - }); -} - -function includePluginDefaults(options) { - options.plugins = options.plugins || {}; - options.plugins.title = (options.plugins.title !== false) && merge(Object.create(null), [ - defaults.plugins.title, - options.plugins.title - ]); - - options.plugins.tooltip = (options.plugins.tooltip !== false) && merge(Object.create(null), [ - defaults.interaction, - defaults.plugins.tooltip, - options.interaction, - options.plugins.tooltip - ]); -} - -function includeDefaults(config, options) { +function initOptions(config, options) { options = options || {}; - const scaleConfig = mergeScaleConfig(config, options); - const hoverEanbled = options.interaction !== false && options.hover !== false; - - options = mergeConfig( - defaults, - defaults.controllers[config.type], - options); - - options.hover = hoverEanbled && merge(Object.create(null), [ - defaults.interaction, - defaults.hover, - options.interaction, - options.hover - ]); - - options.scales = scaleConfig; + options.plugins = valueOrDefault(options.plugins, {}); + options.scales = mergeScaleConfig(config, options); - if (options.plugins !== false) { - includePluginDefaults(options); - } return options; } @@ -144,7 +98,7 @@ function initConfig(config) { data.datasets = data.datasets || []; data.labels = data.labels || []; - config.options = includeDefaults(config, config.options); + config.options = initOptions(config, config.options); return config; } @@ -180,6 +134,135 @@ export default class Config { update(options) { const config = this._config; - config.options = includeDefaults(config, options); + config.options = initOptions(config, options); + } + + /** + * 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 [`datasets.${datasetType}`, `controllers.${datasetType}.datasets`, '']; + } + + /** + * 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 [`datasets.${datasetType}.animation`, `controllers.${datasetType}.datasets.animation`, 'animation']; + } + + /** + * 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 [ + `datasets.${datasetType}`, + `controllers.${datasetType}.datasets`, + `controllers.${datasetType}.elements.${elementType}`, + `elements.${elementType}`, + '' + ]; + } + + /** + * 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 + */ + getOptionScopes(mainScope = {}, scopeKeys) { + const options = this.options; + const scopes = new Set([mainScope]); + + const addIfFound = (obj, key) => { + const opts = resolveObjectKey(obj, key); + if (opts !== undefined) { + scopes.add(opts); + } + }; + + scopeKeys.forEach(key => addIfFound(mainScope, key)); + scopeKeys.forEach(key => addIfFound(options, key)); + scopeKeys.forEach(key => addIfFound(defaults, key)); + + const descriptors = defaults.descriptors; + scopeKeys.forEach(key => addIfFound(descriptors, key)); + + return [...scopes]; + } + + /** + * Returns the option scopes for resolving chart options + * @return {object[]} + */ + chartOptionsScopes() { + return [ + this.options, + defaults.controllers[this.type] || {}, + {type: this.type}, + defaults, defaults.descriptors + ]; + } + + /** + * @param {object[]} scopes + * @param {string[]} names + * @param {function|object} context + * @param {string[]} [prefixes] + * @return {object} + */ + resolveNamedOptions(scopes, names, context, prefixes = ['']) { + const result = {}; + const resolver = _createResolver(scopes, prefixes); + let options; + if (needContext(resolver, names)) { + result.$shared = false; + context = isFunction(context) ? context() : context; + // subResolver os passed to scriptable options. It should not resolve to hover options. + const subPrefixes = prefixes.filter(p => !p.toLowerCase().includes('hover')); + const subResolver = this.createResolver(scopes, context, subPrefixes); + options = _attachContext(resolver, context, subResolver); + } else { + result.$shared = true; + options = resolver; + } + + for (const prop of names) { + result[prop] = options[prop]; + } + return result; + } + + /** + * @param {object[]} scopes + * @param {function|object} context + */ + createResolver(scopes, context, prefixes = ['']) { + const resolver = _createResolver(scopes, prefixes); + return context && needContext(resolver, Object.getOwnPropertyNames(resolver)) + ? _attachContext(resolver, isFunction(context) ? context() : context) + : resolver; + } +} + +function needContext(proxy, names) { + const {isScriptable, isIndexable} = _descriptors(proxy); + + for (const prop of names) { + if ((isScriptable(prop) && isFunction(proxy[prop])) + || (isIndexable(prop) && isArray(proxy[prop]))) { + return true; + } } + return false; } diff --git a/src/core/core.controller.js b/src/core/core.controller.js index 04ec4c191c5..9e9028fcf53 100644 --- a/src/core/core.controller.js +++ b/src/core/core.controller.js @@ -82,9 +82,11 @@ class Chart { ); } + const options = config.createResolver(config.chartOptionsScopes(), me.getContext()); + this.platform = me._initializePlatform(initialCanvas, config); - const context = me.platform.acquireContext(initialCanvas, config); + const context = me.platform.acquireContext(initialCanvas, options.aspectRatio); const canvas = context && context.canvas; const height = canvas && canvas.height; const width = canvas && canvas.width; @@ -95,7 +97,7 @@ class Chart { this.width = width; this.height = height; this.aspectRatio = height ? width / height : null; - this.options = config.options; + this._options = options; this._layers = []; this._metasets = []; this.boxes = []; @@ -144,6 +146,14 @@ class Chart { this.config.data = data; } + get options() { + return this._options; + } + + set options(options) { + this.config.update(options); + } + /** * @private */ @@ -394,9 +404,7 @@ class Chart { const ControllerClass = registry.getController(type); Object.assign(ControllerClass.prototype, { dataElementType: registry.getElement(controllerDefaults.dataElementType), - datasetElementType: controllerDefaults.datasetElementType && registry.getElement(controllerDefaults.datasetElementType), - dataElementOptions: controllerDefaults.dataElementOptions, - datasetElementOptions: controllerDefaults.datasetElementOptions + datasetElementType: controllerDefaults.datasetElementType && registry.getElement(controllerDefaults.datasetElementType) }); meta.controller = new ControllerClass(me, i); newControllers.push(meta.controller); @@ -428,13 +436,15 @@ class Chart { update(mode) { const me = this; + const config = me.config; + + config.update(config.options); + me._options = config.createResolver(config.chartOptionsScopes(), me.getContext()); each(me.scales, (scale) => { layouts.removeBox(me, scale); }); - me.config.update(me.options); - me.options = me.config.options; const animsDisabled = me._animationsDisabled = !me.options.animation; me.ensureScalesHaveIDs(); @@ -992,8 +1002,7 @@ class Chart { */ _updateHoverStyles(active, lastActive, replay) { const me = this; - const options = me.options || {}; - const hoverOptions = options.hover; + const hoverOptions = me.options.hover; const diff = (a, b) => a.filter(x => !b.some(y => x.datasetIndex === y.datasetIndex && x.index === y.index)); const deactivated = diff(lastActive, active); const activated = replay ? active : diff(active, lastActive); diff --git a/src/core/core.datasetController.js b/src/core/core.datasetController.js index b5f50fd091c..bd817aaa002 100644 --- a/src/core/core.datasetController.js +++ b/src/core/core.datasetController.js @@ -1,9 +1,7 @@ import Animations from './core.animations'; import defaults from './core.defaults'; -import {isObject, merge, _merger, isArray, valueOrDefault, mergeIf, resolveObjectKey, _capitalize} from '../helpers/helpers.core'; +import {isObject, isArray, valueOrDefault, resolveObjectKey, defined} from '../helpers/helpers.core'; import {listenArrayEvents, unlistenArrayEvents} from '../helpers/helpers.collection'; -import {resolve} from '../helpers/helpers.options'; -import {getHoverColor} from '../helpers/helpers.color'; import {sign} from '../helpers/helpers.math'; /** @@ -210,8 +208,6 @@ function clearStacks(meta, items) { }); } -const optionKeys = (optionNames) => isArray(optionNames) ? optionNames : Object.keys(optionNames); -const optionKey = (key, active) => active ? 'hover' + _capitalize(key) : key; const isDirectUpdateMode = (mode) => mode === 'reset' || mode === 'none'; const cloneIfNotShared = (cached, shared) => shared ? cached : Object.assign({}, cached); @@ -225,11 +221,10 @@ export default class DatasetController { this.chart = chart; this._ctx = chart.ctx; this.index = datasetIndex; - this._cachedAnimations = {}; this._cachedDataOpts = {}; this._cachedMeta = this.getMeta(); this._type = this._cachedMeta.type; - this._config = undefined; + this.options = undefined; /** @type {boolean | object} */ this._parsing = false; this._data = undefined; @@ -392,21 +387,11 @@ export default class DatasetController { */ configure() { const me = this; - me._config = merge(Object.create(null), [ - defaults.controllers[me._type].datasets, - (me.chart.options.datasets || {})[me._type], - me.getDataset(), - ], { - merger(key, target, source) { - // Cloning the data is expensive and unnecessary. - // Additionally, plugins may add dataset level fields that should - // not be cloned. We identify those via an underscore prefix - if (key !== 'data' && key.charAt(0) !== '_') { - _merger(key, target, source); - } - } - }); - me._parsing = resolve([me._config.parsing, me.chart.options.parsing, true]); + const config = me.chart.config; + const scopeKeys = config.datasetScopeKeys(me._type); + const scopes = config.getOptionScopes(me.getDataset(), scopeKeys); + me.options = config.createResolver(scopes, me.getContext()); + me._parsing = me.options.parsing; } /** @@ -673,10 +658,9 @@ export default class DatasetController { const me = this; const meta = me._cachedMeta; me.configure(); - me._cachedAnimations = {}; me._cachedDataOpts = {}; me.update(mode || 'default'); - meta._clip = toClip(valueOrDefault(me._config.clip, defaultClip(meta.xScale, meta.yScale, me.getMaxOverflow()))); + meta._clip = toClip(valueOrDefault(me.options.clip, defaultClip(meta.xScale, meta.yScale, me.getMaxOverflow()))); } /** @@ -714,21 +698,6 @@ export default class DatasetController { } } - /** - * @private - */ - _addAutomaticHoverColors(index, options) { - const me = this; - const normalOptions = me.getStyle(index); - const missingColors = Object.keys(normalOptions).filter(key => key.indexOf('Color') !== -1 && !(key in options)); - let i = missingColors.length - 1; - let color; - for (; i >= 0; i--) { - color = missingColors[i]; - options[color] = getHoverColor(normalOptions[color]); - } - } - /** * Returns a set of predefined style properties that should be used to represent the dataset * or the data if the index is specified @@ -737,28 +706,16 @@ export default class DatasetController { * @return {object} style object */ getStyle(index, active) { - const me = this; - const meta = me._cachedMeta; - const dataset = meta.dataset; - - if (!me._config) { - me.configure(); - } - - const options = dataset && index === undefined - ? me.resolveDatasetElementOptions(active) - : me.resolveDataElementOptions(index || 0, active && 'active'); - if (active) { - me._addAutomaticHoverColors(index, options); - } - - return options; + const mode = active ? 'active' : 'default'; + return index === undefined && this._cachedMeta.dataset + ? this.resolveDatasetElementOptions(mode) + : this.resolveDataElementOptions(index || 0, mode); } /** * @protected */ - getContext(index, active) { + getContext(index, active, mode) { const me = this; const dataset = me.getDataset(); let context; @@ -771,18 +728,16 @@ export default class DatasetController { } context.active = !!active; + context.mode = mode; return context; } /** - * @param {boolean} [active] + * @param {string} [mode] * @protected */ - resolveDatasetElementOptions(active) { - return this._resolveOptions(this.datasetElementOptions, { - active, - type: this.datasetElementType.id - }); + resolveDatasetElementOptions(mode) { + return this._resolveElementOptions(this.datasetElementType.id, mode); } /** @@ -791,25 +746,33 @@ export default class DatasetController { * @protected */ resolveDataElementOptions(index, mode) { - mode = mode || 'default'; + return this._resolveElementOptions(this.dataElementType.id, mode, index); + } + + /** + * @private + */ + _resolveElementOptions(elementType, mode = 'default', index) { const me = this; const active = mode === 'active'; const cache = me._cachedDataOpts; - const cached = cache[mode]; - const sharing = me.enableOptionSharing; + const cacheKey = elementType + '-' + mode; + const cached = cache[cacheKey]; + const sharing = me.enableOptionSharing && defined(index); if (cached) { return cloneIfNotShared(cached, sharing); } - const info = {cacheable: !active}; - - const values = me._resolveOptions(me.dataElementOptions, { - index, - active, - info, - type: me.dataElementType.id - }); - - if (info.cacheable) { + const config = me.chart.config; + const scopeKeys = config.datasetElementScopeKeys(me._type, elementType); + const prefixes = active ? [`${elementType}Hover`, 'hover', elementType, ''] : [elementType, '']; + const scopes = config.getOptionScopes(me.getDataset(), scopeKeys); + const names = Object.keys(defaults.elements[elementType]); + // context is provided as a function, and is called only if needed, + // so we don't create a context for each element if not needed. + const context = () => me.getContext(index, active); + const values = config.resolveNamedOptions(scopes, names, context, prefixes); + + if (values.$shared) { // `$shared` indicates this set of options can be shared between multiple elements. // Sharing is used to reduce number of properties to change during animation. values.$shared = sharing; @@ -817,41 +780,12 @@ export default class DatasetController { // We cache options by `mode`, which can be 'active' for example. This enables us // to have the 'active' element options and 'default' options to switch between // when interacting. - // We freeze a clone of this object, so the returned values are not frozen. - cache[mode] = Object.freeze(Object.assign({}, values)); + cache[cacheKey] = Object.freeze(cloneIfNotShared(values, sharing)); } return values; } - /** - * @private - */ - _resolveOptions(optionNames, args) { - const me = this; - const {index, active, type, info} = args; - const datasetOpts = me._config; - const options = me.chart.options.elements[type] || {}; - const values = {}; - const context = me.getContext(index, active); - const keys = optionKeys(optionNames); - - for (let i = 0, ilen = keys.length; i < ilen; ++i) { - const key = keys[i]; - const readKey = optionKey(key, active); - const value = resolve([ - datasetOpts[optionNames[readKey]], - datasetOpts[readKey], - options[readKey] - ], context, index, info); - - if (value !== undefined) { - values[key] = value; - } - } - - return values; - } /** * @private @@ -859,29 +793,24 @@ export default class DatasetController { _resolveAnimations(index, mode, active) { const me = this; const chart = me.chart; - const cached = me._cachedAnimations; - mode = mode || 'default'; - - if (cached[mode]) { - return cached[mode]; + const cache = me._cachedDataOpts; + const cacheKey = 'animation-' + mode; + const cached = cache[cacheKey]; + if (cached) { + return cached; } - - const info = {cacheable: true}; - const context = me.getContext(index, active); - const chartAnim = resolve([chart.options.animation], context, index, info); - const datasetAnim = resolve([me._config.animation], context, index, info); - let config = chartAnim && mergeIf({}, [datasetAnim, chartAnim]); - - if (config[mode]) { - config = Object.assign({}, config, config[mode]); + 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); + const context = () => me.getContext(index, active, mode); + options = config.createResolver(scopes, context); } - - const animations = new Animations(chart, config); - - if (info.cacheable) { - cached[mode] = animations && Object.freeze(animations); + const animations = new Animations(chart, options && options[mode] || options); + if (options && options._cacheable) { + cache[cacheKey] = Object.freeze(animations); } - return animations; } @@ -922,7 +851,7 @@ export default class DatasetController { */ updateSharedOptions(sharedOptions, mode, newOptions) { if (sharedOptions) { - this._resolveAnimations(undefined, mode).update({options: sharedOptions}, {options: newOptions}); + this._resolveAnimations(undefined, mode).update(sharedOptions, newOptions); } } @@ -932,7 +861,11 @@ export default class DatasetController { _setStyle(element, index, mode, active) { element.active = active; const options = this.getStyle(index, active); - this._resolveAnimations(index, mode, active).update(element, {options: this.getSharedOptions(options) || options}); + this._resolveAnimations(index, mode, active).update(element, { + // When going from active to inactive, we need to update to the shared options. + // This way the once hovered element will end up with the same original shared options instance, after animation. + options: (!active && this.getSharedOptions(options)) || options + }); } removeHoverStyle(element, datasetIndex, index) { @@ -1087,32 +1020,3 @@ DatasetController.prototype.datasetElementType = null; * Element type used to generate a meta data (e.g. Chart.element.PointElement). */ DatasetController.prototype.dataElementType = null; - -/** - * Dataset element option keys to be resolved in resolveDatasetElementOptions. - * A derived controller may override this to resolve controller-specific options. - * The keys defined here are for backward compatibility for legend styles. - * @type {string[]} - */ -DatasetController.prototype.datasetElementOptions = [ - 'backgroundColor', - 'borderCapStyle', - 'borderColor', - 'borderDash', - 'borderDashOffset', - 'borderJoinStyle', - 'borderWidth' -]; - -/** - * Data element option keys to be resolved in resolveDataElementOptions. - * A derived controller may override this to resolve controller-specific options. - * The keys defined here are for backward compatibility for legend styles. - * @type {string[]|object} - */ -DatasetController.prototype.dataElementOptions = [ - 'backgroundColor', - 'borderColor', - 'borderWidth', - 'pointStyle' -]; diff --git a/src/core/core.defaults.js b/src/core/core.defaults.js index 14b16e1c4f0..94dc1a9ef5f 100644 --- a/src/core/core.defaults.js +++ b/src/core/core.defaults.js @@ -1,5 +1,8 @@ +import {getHoverColor} from '../helpers/helpers.color'; import {isObject, merge, valueOrDefault} from '../helpers/helpers.core'; +const privateSymbol = Symbol('private'); + /** * @param {object} node * @param {string} key @@ -22,11 +25,13 @@ function getScope(node, key) { * Note: class is exported for typedoc */ export class Defaults { - constructor() { + constructor(descriptors) { + this.animation = undefined; this.backgroundColor = 'rgba(0,0,0,0.1)'; this.borderColor = 'rgba(0,0,0,0.1)'; this.color = '#666'; this.controllers = {}; + this.devicePixelRatio = (context) => context.chart.platform.getDevicePixelRatio(); this.elements = {}; this.events = [ 'mousemove', @@ -45,6 +50,10 @@ export class Defaults { this.hover = { onHover: null }; + this.hoverBackgroundColor = (ctx, options) => getHoverColor(options.backgroundColor); + this.hoverBorderColor = (ctx, options) => getHoverColor(options.borderColor); + this.hoverColor = (ctx, options) => getHoverColor(options.color); + this.indexAxis = 'x'; this.interaction = { mode: 'nearest', intersect: true @@ -52,11 +61,19 @@ export class Defaults { this.maintainAspectRatio = true; this.onHover = null; this.onClick = null; + this.parsing = true; this.plugins = {}; this.responsive = true; this.scale = undefined; this.scales = {}; this.showLine = true; + + Object.defineProperty(this, privateSymbol, { + value: Object.create(null), + writable: false + }); + + this.describe(descriptors); } /** @@ -77,6 +94,22 @@ export class Defaults { return getScope(this, scope); } + /** + * @param {string|object} scope + * @param {object} [values] + */ + describe(scope, values) { + const root = this[privateSymbol]; + if (typeof scope === 'string') { + return merge(getScope(root, scope), values); + } + return merge(getScope(root, ''), scope); + } + + get descriptors() { + return this[privateSymbol]; + } + /** * Routes the named defaults to fallback to another scope/name. * This routing is useful when those target values, like defaults.color, are changed runtime. @@ -125,4 +158,14 @@ export class Defaults { } // singleton instance -export default new Defaults(); +export default new Defaults({ + _scriptable: (name) => name !== 'onClick' && name !== 'onHover', + _indexable: (name) => name !== 'events', + hover: { + _fallback: 'interaction' + }, + interaction: { + _scriptable: false, + _indexable: false, + } +}); diff --git a/src/core/core.layouts.js b/src/core/core.layouts.js index 31a4cb618a1..30661a17f0d 100644 --- a/src/core/core.layouts.js +++ b/src/core/core.layouts.js @@ -1,6 +1,6 @@ import defaults from './core.defaults'; import {each, isObject} from '../helpers/helpers.core'; -import {toPadding, resolve} from '../helpers/helpers.options'; +import {toPadding} from '../helpers/helpers.options'; /** * @typedef { import("./core.controller").default } Chart @@ -301,10 +301,7 @@ export default { return; } - const layoutOptions = chart.options.layout || {}; - const context = {chart}; - const padding = toPadding(resolve([layoutOptions.padding], context)); - + const padding = toPadding(chart.options.layout.padding); const availableWidth = width - padding.width; const availableHeight = height - padding.height; const boxes = buildLayoutBoxes(chart.boxes); diff --git a/src/core/core.plugins.js b/src/core/core.plugins.js index 42f962c5b47..af9dca89c35 100644 --- a/src/core/core.plugins.js +++ b/src/core/core.plugins.js @@ -1,7 +1,5 @@ -import defaults from './core.defaults'; import registry from './core.registry'; -import {isNullOrUndef} from '../helpers'; -import {callback as callCallback, mergeIf, valueOrDefault} from '../helpers/helpers.core'; +import {callback as callCallback, isNullOrUndef, valueOrDefault} from '../helpers/helpers.core'; /** * @typedef { import("./core.controller").default } Chart @@ -91,7 +89,7 @@ export default class PluginService { const options = valueOrDefault(config.options && config.options.plugins, {}); const plugins = allPlugins(config); // options === false => all plugins are disabled - return options === false && !all ? [] : createDescriptors(plugins, options, all); + return options === false && !all ? [] : createDescriptors(chart, plugins, options, all); } /** @@ -139,8 +137,9 @@ function getOpts(options, all) { return options; } -function createDescriptors(plugins, options, all) { +function createDescriptors(chart, plugins, options, all) { const result = []; + const context = chart.getContext(); for (let i = 0; i < plugins.length; i++) { const plugin = plugins[i]; @@ -151,9 +150,27 @@ function createDescriptors(plugins, options, all) { } result.push({ plugin, - options: mergeIf({}, [opts, defaults.plugins[id]]) + options: pluginOpts(chart.config, plugin, opts, context) }); } return result; } + +/** + * @param {import("./core.config").default} config + * @param {*} plugin + * @param {*} opts + * @param {*} context + */ +function pluginOpts(config, plugin, opts, context) { + const id = plugin.id; + const keys = [ + `controllers.${config.type}.plugins.${id}`, + `plugins.${id}`, + ...plugin.additionalOptionScopes || [], + '' + ]; + const scopes = config.getOptionScopes(opts || {}, keys); + return config.createResolver(scopes, context); +} diff --git a/src/core/core.scale.js b/src/core/core.scale.js index 70fdfaca695..a1eab6cdf82 100644 --- a/src/core/core.scale.js +++ b/src/core/core.scale.js @@ -3,7 +3,7 @@ import Element from './core.element'; import {_alignPixel, _measureText, renderText, clipArea, unclipArea} from '../helpers/helpers.canvas'; import {callback as call, each, finiteOrDefault, isArray, isFinite, isNullOrUndef, isObject, valueOrDefault} from '../helpers/helpers.core'; import {_factorize, toDegrees, toRadians, _int16Range, HALF_PI} from '../helpers/helpers.math'; -import {toFont, resolve, toPadding} from '../helpers/helpers.options'; +import {toFont, toPadding} from '../helpers/helpers.options'; import Ticks from './core.ticks'; /** @@ -34,9 +34,13 @@ defaults.set('scale', { drawOnChartArea: true, drawTicks: true, tickLength: 10, + tickWidth: (_ctx, options) => options.lineWidth, + tickColor: (_ctx, options) => options.color, offsetGridLines: false, borderDash: [], - borderDashOffset: 0.0 + borderDashOffset: 0.0, + borderColor: (_ctx, options) => options.color, + borderWidth: (_ctx, options) => options.lineWidth }, // scale label @@ -79,6 +83,12 @@ defaults.route('scale.ticks', 'color', '', 'color'); defaults.route('scale.gridLines', 'color', '', 'borderColor'); defaults.route('scale.scaleLabel', 'color', '', 'color'); +defaults.describe('scales', { + _fallback: 'scale', + _scriptable: (name) => !name.startsWith('before') && !name.startsWith('after') && name !== 'callback' && name !== 'parser', + _indexable: (name) => name !== 'borderDash' && name !== 'tickBorderDash', +}); + /** * Returns a new array containing numItems from arr * @param {any[]} arr @@ -396,7 +406,7 @@ export default class Scale extends Element { const me = this; me.options = options; - me.axis = me.isHorizontal() ? 'x' : 'y'; + me.axis = options.axis; // parse min/max value, so we can properly determine min/max for other scales me._userMin = me.parse(options.min); @@ -1206,8 +1216,8 @@ export default class Scale extends Element { const tl = getTickMarkLength(gridLines); const items = []; - let context = this.getContext(0); - const axisWidth = gridLines.drawBorder ? resolve([gridLines.borderWidth, gridLines.lineWidth, 0], context, 0) : 0; + const borderOpts = gridLines.setContext(me.getContext(0)); + const axisWidth = borderOpts.drawBorder ? borderOpts.borderWidth : 0; const axisHalfWidth = axisWidth / 2; const alignBorderValue = function(pixel) { return _alignPixel(chart, pixel, axisWidth); @@ -1268,17 +1278,17 @@ export default class Scale extends Element { } for (i = 0; i < ticksLength; ++i) { - context = this.getContext(i); + const optsAtIndex = gridLines.setContext(me.getContext(i)); - const lineWidth = resolve([gridLines.lineWidth], context, i); - const lineColor = resolve([gridLines.color], context, i); + const lineWidth = optsAtIndex.lineWidth; + const lineColor = optsAtIndex.color; const borderDash = gridLines.borderDash || []; - const borderDashOffset = resolve([gridLines.borderDashOffset], context, i); + const borderDashOffset = optsAtIndex.borderDashOffset; - const tickWidth = resolve([gridLines.tickWidth, lineWidth], context, i); - const tickColor = resolve([gridLines.tickColor, lineColor], context, i); - const tickBorderDash = gridLines.tickBorderDash || borderDash; - const tickBorderDashOffset = resolve([gridLines.tickBorderDashOffset, borderDashOffset], context, i); + const tickWidth = optsAtIndex.tickWidth; + const tickColor = optsAtIndex.tickColor; + const tickBorderDash = optsAtIndex.tickBorderDash || []; + const tickBorderDashOffset = optsAtIndex.tickBorderDashOffset; lineValue = getPixelForGridLine(me, i, offsetGridLines); @@ -1386,14 +1396,15 @@ export default class Scale extends Element { tick = ticks[i]; label = tick.label; + const optsAtIndex = optionTicks.setContext(me.getContext(i)); pixel = me.getPixelForTick(i) + optionTicks.labelOffset; font = me._resolveTickFontOptions(i); lineHeight = font.lineHeight; lineCount = isArray(label) ? label.length : 1; const halfCount = lineCount / 2; - const color = resolve([optionTicks.color], me.getContext(i), i); - const strokeColor = resolve([optionTicks.textStrokeColor], me.getContext(i), i); - const strokeWidth = resolve([optionTicks.textStrokeWidth], me.getContext(i), i); + const color = optsAtIndex.color; + const strokeColor = optsAtIndex.textStrokeColor; + const strokeWidth = optsAtIndex.textStrokeWidth; if (isHorizontal) { x = pixel; @@ -1539,8 +1550,8 @@ export default class Scale extends Element { const gridLines = me.options.gridLines; const ctx = me.ctx; const chart = me.chart; - let context = me.getContext(0); - const axisWidth = gridLines.drawBorder ? resolve([gridLines.borderWidth, gridLines.lineWidth, 0], context, 0) : 0; + const borderOpts = gridLines.setContext(me.getContext(0)); + const axisWidth = gridLines.drawBorder ? borderOpts.borderWidth : 0; const items = me._gridLineItems || (me._gridLineItems = me._computeGridLineItems(chartArea)); let i, ilen; @@ -1585,24 +1596,23 @@ export default class Scale extends Element { if (axisWidth) { // Draw the line at the edge of the axis - const firstLineWidth = axisWidth; - context = me.getContext(me._ticksLength - 1); - const lastLineWidth = resolve([gridLines.lineWidth, 1], context, me._ticksLength - 1); + const edgeOpts = gridLines.setContext(me.getContext(me._ticksLength - 1)); + const lastLineWidth = edgeOpts.lineWidth; const borderValue = me._borderValue; let x1, x2, y1, y2; if (me.isHorizontal()) { - x1 = _alignPixel(chart, me.left, firstLineWidth) - firstLineWidth / 2; + x1 = _alignPixel(chart, me.left, axisWidth) - axisWidth / 2; x2 = _alignPixel(chart, me.right, lastLineWidth) + lastLineWidth / 2; y1 = y2 = borderValue; } else { - y1 = _alignPixel(chart, me.top, firstLineWidth) - firstLineWidth / 2; + y1 = _alignPixel(chart, me.top, axisWidth) - axisWidth / 2; y2 = _alignPixel(chart, me.bottom, lastLineWidth) + lastLineWidth / 2; x1 = x2 = borderValue; } ctx.lineWidth = axisWidth; - ctx.strokeStyle = resolve([gridLines.borderColor, gridLines.color], context, 0); + ctx.strokeStyle = edgeOpts.borderColor; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); @@ -1788,11 +1798,8 @@ export default class Scale extends Element { * @protected */ _resolveTickFontOptions(index) { - const me = this; - const chart = me.chart; - const options = me.options.ticks; - const context = me.getContext(index); - return toFont(resolve([options.font], context), chart.options.font); + const opts = this.options.ticks.setContext(this.getContext(index)); + return toFont(opts.font); } } diff --git a/src/core/core.typedRegistry.js b/src/core/core.typedRegistry.js index 45df85a1d17..76306cbe0e7 100644 --- a/src/core/core.typedRegistry.js +++ b/src/core/core.typedRegistry.js @@ -88,6 +88,10 @@ function registerDefaults(item, scope, parentScope) { if (item.defaultRoutes) { routeDefaults(scope, item.defaultRoutes); } + + if (item.descriptors) { + defaults.describe(scope, item.descriptors); + } } function routeDefaults(scope, routes) { diff --git a/src/elements/element.arc.js b/src/elements/element.arc.js index 286008c3476..d2123c425a6 100644 --- a/src/elements/element.arc.js +++ b/src/elements/element.arc.js @@ -214,7 +214,8 @@ ArcElement.defaults = { borderAlign: 'center', borderColor: '#fff', borderWidth: 2, - offset: 0 + offset: 0, + angle: undefined }; /** diff --git a/src/elements/element.bar.js b/src/elements/element.bar.js index c12b7a45324..3e2860ffd4d 100644 --- a/src/elements/element.bar.js +++ b/src/elements/element.bar.js @@ -256,7 +256,8 @@ BarElement.id = 'bar'; BarElement.defaults = { borderSkipped: 'start', borderWidth: 0, - borderRadius: 0 + borderRadius: 0, + pointStyle: undefined }; /** diff --git a/src/elements/element.line.js b/src/elements/element.line.js index 9aec82a729a..08aa993b578 100644 --- a/src/elements/element.line.js +++ b/src/elements/element.line.js @@ -395,8 +395,11 @@ LineElement.defaults = { borderJoinStyle: 'miter', borderWidth: 3, capBezierPoints: true, + cubicInterpolationMode: 'default', fill: false, - tension: 0 + spanGaps: false, + stepped: false, + tension: 0, }; /** @@ -406,3 +409,9 @@ LineElement.defaultRoutes = { backgroundColor: 'backgroundColor', borderColor: 'borderColor' }; + + +LineElement.descriptors = { + _scriptable: true, + _indexable: (name) => name !== 'borderDash' && name !== 'fill', +}; diff --git a/src/elements/element.point.js b/src/elements/element.point.js index 8ee204c29f0..0190626a37f 100644 --- a/src/elements/element.point.js +++ b/src/elements/element.point.js @@ -77,7 +77,8 @@ PointElement.defaults = { hoverBorderWidth: 1, hoverRadius: 4, pointStyle: 'circle', - radius: 3 + radius: 3, + rotation: 0 }; /** diff --git a/src/helpers/helpers.config.js b/src/helpers/helpers.config.js new file mode 100644 index 00000000000..2f61a791597 --- /dev/null +++ b/src/helpers/helpers.config.js @@ -0,0 +1,230 @@ +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. + * @returns Proxy + * @private + */ +export function _createResolver(scopes, prefixes = ['']) { + const cache = { + [Symbol.toStringTag]: 'Object', + _cacheable: true, + _scopes: scopes, + override: (scope) => _createResolver([scope].concat(scopes), prefixes), + }; + return new Proxy(cache, { + get(target, prop) { + return _cached(target, prop, + () => _resolveWithPrefixes(prop, prefixes, scopes)); + }, + + ownKeys(target) { + return getKeysFromAllScopes(target); + }, + + getOwnPropertyDescriptor(target, prop) { + return Reflect.getOwnPropertyDescriptor(target._scopes[0], prop); + }, + + set(target, prop, value) { + scopes[0][prop] = value; + return delete target[prop]; + } + }); +} + +/** + * Returns an Proxy for resolving option values with context. + * @param {object} proxy - The Proxy returned by `_createResolver` + * @param {object} context - Context object for scriptable/indexable options + * @param {object} [subProxy] - The proxy provided for scriptable options + * @private + */ +export function _attachContext(proxy, context, subProxy) { + const cache = { + _cacheable: false, + _proxy: proxy, + _context: context, + _subProxy: subProxy, + _stack: new Set(), + _descriptors: _descriptors(proxy), + setContext: (ctx) => _attachContext(proxy, ctx, subProxy), + override: (scope) => _attachContext(proxy.override(scope), context, subProxy) + }; + return new Proxy(cache, { + get(target, prop, receiver) { + return _cached(target, prop, + () => _resolveWithContext(target, prop, receiver)); + }, + + ownKeys() { + return Reflect.ownKeys(proxy); + }, + + getOwnPropertyDescriptor(target, prop) { + return Reflect.getOwnPropertyDescriptor(proxy._scopes[0], prop); + }, + + set(target, prop, value) { + proxy[prop] = value; + return delete target[prop]; + } + }); +} + +/** + * @private + */ +export function _descriptors(proxy) { + const {_scriptable = true, _indexable = true} = proxy; + return { + isScriptable: isFunction(_scriptable) ? _scriptable : () => _scriptable, + isIndexable: isFunction(_indexable) ? _indexable : () => _indexable + }; +} + +const readKey = (prefix, name) => prefix ? prefix + _capitalize(name) : name; +const needsSubResolver = (prop, value) => isObject(value); + +function _cached(target, prop, resolve) { + let value = target[prop]; // cached value + if (defined(value)) { + return value; + } + + value = resolve(); + + if (defined(value)) { + // cache the resolved value + target[prop] = value; + } + return value; +} + +function _resolveWithContext(target, prop, receiver) { + const {_proxy, _context, _subProxy, _descriptors: descriptors} = target; + let value = _proxy[prop]; // resolve from proxy + + // resolve with context + if (isFunction(value) && descriptors.isScriptable(prop)) { + value = _resolveScriptable(prop, value, target, receiver); + } + if (isArray(value) && value.length) { + value = _resolveArray(prop, value, target, descriptors.isIndexable); + } + if (needsSubResolver(prop, value)) { + // if the resolved value is an object, crate a sub resolver for it + value = _attachContext(value, _context, _subProxy && _subProxy[prop]); + } + return value; +} + +function _resolveScriptable(prop, value, target, receiver) { + const {_proxy, _context, _subProxy, _stack} = target; + if (_stack.has(prop)) { + // @ts-ignore + throw new Error('Recursion detected: ' + [..._stack].join('->') + '->' + prop); + } + _stack.add(prop); + value = value(_context, _subProxy || receiver); + _stack.delete(prop); + if (isObject(value)) { + // When scriptable option returns an object, create a resolver on that. + value = createSubResolver([value].concat(_proxy._scopes), prop, value); + } + return value; +} + +function _resolveArray(prop, value, target, isIndexable) { + const {_proxy, _context, _subProxy} = target; + + if (defined(_context.index) && isIndexable(prop)) { + value = value[_context.index % value.length]; + } else if (isObject(value[0])) { + // Array of objects, return array or resolvers + const arr = value; + const scopes = _proxy._scopes.filter(s => s !== arr); + value = []; + for (const item of arr) { + const resolver = createSubResolver([item].concat(scopes), prop, item); + value.push(_attachContext(resolver, _context, _subProxy && _subProxy[prop])); + } + } + return value; +} + +function createSubResolver(parentScopes, prop, value) { + const set = new Set([value]); + const {keys, includeParents} = _resolveSubKeys(parentScopes, prop, value); + for (const key of keys) { + for (const item of parentScopes) { + const scope = resolveObjectKey(item, key); + if (scope) { + set.add(scope); + } 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; + } + } + } + if (includeParents) { + parentScopes.forEach(set.add, set); + } + return _createResolver([...set]); +} + +function _resolveSubKeys(parentScopes, prop, value) { + const fallback = _resolve('_fallback', parentScopes.map(scope => scope[prop] || scope)); + const keys = [prop]; + if (defined(fallback)) { + const resolved = isFunction(fallback) ? fallback(prop, value) : fallback; + keys.push(...(isArray(resolved) ? resolved : [resolved])); + } + return {keys: keys.filter(v => v), includeParents: fallback !== prop}; +} + +function _resolveWithPrefixes(prop, prefixes, scopes) { + let value; + for (const prefix of prefixes) { + value = _resolve(readKey(prefix, prop), scopes); + if (defined(value)) { + return (needsSubResolver(prop, value)) + ? createSubResolver(scopes, prop, value) + : value; + } + } +} + +function _resolve(key, scopes) { + for (const scope of scopes) { + if (!scope) { + continue; + } + const value = scope[key]; + if (defined(value)) { + return value; + } + } +} + +function getKeysFromAllScopes(target) { + let keys = target._keys; + if (!keys) { + keys = target._keys = resolveKeysFromAllScopes(target._scopes); + } + return keys; +} + +function resolveKeysFromAllScopes(scopes) { + const set = new Set(); + for (const scope of scopes) { + for (const key of Object.keys(scope).filter(k => !k.startsWith('_'))) { + set.add(key); + } + } + return [...set]; +} diff --git a/src/helpers/helpers.core.js b/src/helpers/helpers.core.js index a0c63fde08b..20c0f6e3bdf 100644 --- a/src/helpers/helpers.core.js +++ b/src/helpers/helpers.core.js @@ -309,3 +309,8 @@ export function resolveObjectKey(obj, key) { export function _capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } + + +export const defined = (value) => typeof value !== 'undefined'; + +export const isFunction = (value) => typeof value === 'function'; diff --git a/src/helpers/index.js b/src/helpers/index.js index b45d88433ce..9861601c4da 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -1,6 +1,7 @@ export * from './helpers.core'; export * from './helpers.canvas'; export * from './helpers.collection'; +export * from './helpers.config'; export * from './helpers.curve'; export * from './helpers.dom'; export {default as easingEffects} from './helpers.easing'; diff --git a/src/platform/platform.base.js b/src/platform/platform.base.js index 5b5851ebab7..44803cdbd23 100644 --- a/src/platform/platform.base.js +++ b/src/platform/platform.base.js @@ -11,9 +11,9 @@ export default class BasePlatform { * Called at chart construction time, returns a context2d instance implementing * the [W3C Canvas 2D Context API standard]{@link https://www.w3.org/TR/2dcontext/}. * @param {HTMLCanvasElement} canvas - The canvas from which to acquire context (platform specific) - * @param {object} options - The chart options + * @param {number} [aspectRatio] - The chart options */ - acquireContext(canvas, options) {} // eslint-disable-line no-unused-vars + acquireContext(canvas, aspectRatio) {} // eslint-disable-line no-unused-vars /** * Called at chart destruction time, releases any resources associated to the context diff --git a/src/platform/platform.dom.js b/src/platform/platform.dom.js index 1b6fb13f7e3..7cbe719f27d 100644 --- a/src/platform/platform.dom.js +++ b/src/platform/platform.dom.js @@ -36,9 +36,9 @@ const isNullOrEmpty = value => value === null || value === ''; * since responsiveness is handled by the controller.resize() method. The config is used * to determine the aspect ratio to apply in case no explicit height has been specified. * @param {HTMLCanvasElement} canvas - * @param {{ options: any; }} config + * @param {number} [aspectRatio] */ -function initCanvas(canvas, config) { +function initCanvas(canvas, aspectRatio) { const style = canvas.style; // NOTE(SB) canvas.getAttribute('width') !== canvas.width: in the first case it @@ -78,7 +78,7 @@ function initCanvas(canvas, config) { // If no explicit render height and style height, let's apply the aspect ratio, // which one can be specified by the user but also by charts as default option // (i.e. options.aspectRatio). If not specified, use canvas aspect ratio of 2. - canvas.height = canvas.width / (config.options.aspectRatio || 2); + canvas.height = canvas.width / (aspectRatio || 2); } else { const displayHeight = readUsedSize(canvas, 'height'); if (displayHeight !== undefined) { @@ -259,10 +259,10 @@ export default class DomPlatform extends BasePlatform { /** * @param {HTMLCanvasElement} canvas - * @param {{ options: { aspectRatio?: number; }; }} config + * @param {number} [aspectRatio] * @return {CanvasRenderingContext2D|null} */ - acquireContext(canvas, config) { + acquireContext(canvas, aspectRatio) { // To prevent canvas fingerprinting, some add-ons undefine the getContext // method, for example: https://github.com/kkapsner/CanvasBlocker // https://github.com/chartjs/Chart.js/issues/2807 @@ -278,7 +278,7 @@ export default class DomPlatform extends BasePlatform { if (context && context.canvas === canvas) { // Load platform resources on first chart creation, to make it possible to // import the library before setting platform options. - initCanvas(canvas, config); + initCanvas(canvas, aspectRatio); return context; } diff --git a/src/plugins/plugin.legend.js b/src/plugins/plugin.legend.js index af3eaef5b32..342cd615a54 100644 --- a/src/plugins/plugin.legend.js +++ b/src/plugins/plugin.legend.js @@ -614,5 +614,12 @@ export default { defaultRoutes: { 'labels.color': 'color', 'title.color': 'color' + }, + + descriptors: { + _scriptable: (name) => !name.startsWith('on'), + labels: { + _scriptable: false, + } } }; diff --git a/src/plugins/plugin.tooltip.js b/src/plugins/plugin.tooltip.js index 566481554f4..41755641a24 100644 --- a/src/plugins/plugin.tooltip.js +++ b/src/plugins/plugin.tooltip.js @@ -1,6 +1,6 @@ import Animations from '../core/core.animations'; import Element from '../core/core.element'; -import {each, noop, isNullOrUndef, isArray, _elementsEqual, valueOrDefault} from '../helpers/helpers.core'; +import {each, noop, isNullOrUndef, isArray, _elementsEqual} from '../helpers/helpers.core'; import {getRtlAdapter, overrideTextDirection, restoreTextDirection} from '../helpers/helpers.rtl'; import {distanceBetweenPoints} from '../helpers/helpers.math'; import {drawPoint, toFontString} from '../helpers'; @@ -368,9 +368,6 @@ export class Tooltip extends Element { } initialize(options) { - const defaultSize = options.bodyFont.size; - options.boxHeight = valueOrDefault(options.boxHeight, defaultSize); - options.boxWidth = valueOrDefault(options.boxWidth, defaultSize); this.options = options; this._cachedAnimations = undefined; } @@ -1102,6 +1099,8 @@ export default { caretPadding: 2, caretSize: 5, cornerRadius: 6, + boxHeight: (ctx, opts) => opts.bodyFont.size, + boxWidth: (ctx, opts) => opts.bodyFont.size, multiKeyBackground: '#fff', displayColors: true, borderColor: 'rgba(0,0,0,0)', @@ -1196,5 +1195,16 @@ export default { bodyFont: 'font', footerFont: 'font', titleFont: 'font' - } + }, + + descriptors: { + _scriptable: (name) => name !== 'filter' && name !== 'itemSort' && name !== 'custom', + _indexable: false, + callbacks: { + _scriptable: false, + _indexable: false, + } + }, + + additionalOptionScopes: ['interaction'] }; diff --git a/src/scales/scale.radialLinear.js b/src/scales/scale.radialLinear.js index 45e938e2938..61415d7edf6 100644 --- a/src/scales/scale.radialLinear.js +++ b/src/scales/scale.radialLinear.js @@ -4,7 +4,7 @@ import {HALF_PI, isNumber, TAU, toDegrees, toRadians, _normalizeAngle} from '../ import LinearScaleBase from './scale.linearbase'; import Ticks from '../core/core.ticks'; import {valueOrDefault, isArray, isFinite, callback as callCallback, isNullOrUndef} from '../helpers/helpers.core'; -import {toFont, resolve} from '../helpers/helpers.options'; +import {toFont} from '../helpers/helpers.options'; function getTickBackdropHeight(opts) { const tickOpts = opts.ticks; @@ -95,9 +95,8 @@ function fitWithPointLabels(scale) { const valueCount = scale.chart.data.labels.length; for (i = 0; i < valueCount; i++) { pointPosition = scale.getPointPosition(i, scale.drawingArea + 5); - - const context = scale.getContext(i); - const plFont = toFont(resolve([scale.options.pointLabels.font], context, i), scale.chart.options.font); + const opts = scale.options.pointLabels.setContext(scale.getContext(i)); + const plFont = toFont(opts.font); scale.ctx.font = plFont.string; textSize = measureLabelSize(scale.ctx, plFont.lineHeight, scale.pointLabels[i]); scale._pointLabelSizes[i] = textSize; @@ -166,8 +165,8 @@ function drawPointLabels(scale) { const extra = (i === 0 ? tickBackdropHeight / 2 : 0); const pointLabelPosition = scale.getPointPosition(i, outerDistance + extra + 5); - const context = scale.getContext(i); - const plFont = toFont(resolve([pointLabelOpts.font], context, i), scale.chart.options.font); + const optsAtIndex = pointLabelOpts.setContext(scale.getContext(i)); + const plFont = toFont(optsAtIndex.font); const angle = toDegrees(scale.getIndexAngle(i)); adjustPointPositionForLabelHeight(angle, scale._pointLabelSizes[i], pointLabelPosition); renderText( @@ -177,7 +176,7 @@ function drawPointLabels(scale) { pointLabelPosition.y + (plFont.lineHeight / 2), plFont, { - color: resolve([pointLabelOpts.color], context, i), + color: optsAtIndex.color, textAlign: getTextAlignForAngle(angle), } ); @@ -185,14 +184,13 @@ function drawPointLabels(scale) { ctx.restore(); } -function drawRadiusLine(scale, gridLineOpts, radius, index) { +function drawRadiusLine(scale, gridLineOpts, radius) { const ctx = scale.ctx; const circular = gridLineOpts.circular; const valueCount = scale.chart.data.labels.length; - const context = scale.getContext(index); - const lineColor = resolve([gridLineOpts.color], context, index - 1); - const lineWidth = resolve([gridLineOpts.lineWidth], context, index - 1); + const lineColor = gridLineOpts.color; + const lineWidth = gridLineOpts.lineWidth; let pointPosition; if ((!circular && !valueCount) || !lineColor || !lineWidth || radius < 0) { @@ -202,10 +200,8 @@ function drawRadiusLine(scale, gridLineOpts, radius, index) { ctx.save(); ctx.strokeStyle = lineColor; ctx.lineWidth = lineWidth; - if (ctx.setLineDash) { - ctx.setLineDash(resolve([gridLineOpts.borderDash, []], context)); - ctx.lineDashOffset = resolve([gridLineOpts.borderDashOffset], context, index - 1); - } + ctx.setLineDash(gridLineOpts.borderDash); + ctx.lineDashOffset = gridLineOpts.borderDashOffset; ctx.beginPath(); if (circular) { @@ -245,11 +241,6 @@ export default class RadialLinearScale extends LinearScaleBase { this.pointLabels = []; } - init(options) { - super.init(options); - this.axis = 'r'; - } - setDimensions() { const me = this; @@ -408,7 +399,8 @@ export default class RadialLinearScale extends LinearScaleBase { me.ticks.forEach((tick, index) => { if (index !== 0) { offset = me.getDistanceFromCenterForValue(me.ticks[index].value); - drawRadiusLine(me, gridLineOpts, offset, index); + const optsAtIndex = gridLineOpts.setContext(me.getContext(index - 1)); + drawRadiusLine(me, optsAtIndex, offset); } }); } @@ -417,9 +409,9 @@ export default class RadialLinearScale extends LinearScaleBase { ctx.save(); for (i = me.chart.data.labels.length - 1; i >= 0; i--) { - const context = me.getContext(i); - const lineWidth = resolve([angleLineOpts.lineWidth, gridLineOpts.lineWidth], context, i); - const color = resolve([angleLineOpts.color, gridLineOpts.color], context, i); + const optsAtIndex = angleLineOpts.setContext(me.getContext(i)); + const lineWidth = optsAtIndex.lineWidth; + const color = optsAtIndex.color; if (!lineWidth || !color) { continue; @@ -428,10 +420,8 @@ export default class RadialLinearScale extends LinearScaleBase { ctx.lineWidth = lineWidth; ctx.strokeStyle = color; - if (ctx.setLineDash) { - ctx.setLineDash(resolve([angleLineOpts.borderDash, gridLineOpts.borderDash, []], context)); - ctx.lineDashOffset = resolve([angleLineOpts.borderDashOffset, gridLineOpts.borderDashOffset, 0.0], context, i); - } + ctx.setLineDash(optsAtIndex.borderDash); + ctx.lineDashOffset = optsAtIndex.borderDashOffset; offset = me.getDistanceFromCenterForValue(opts.ticks.reverse ? me.min : me.max); position = me.getPointPosition(i, offset); @@ -472,26 +462,24 @@ export default class RadialLinearScale extends LinearScaleBase { return; } - const context = me.getContext(index); - const tickFont = me._resolveTickFontOptions(index); + const optsAtIndex = tickOpts.setContext(me.getContext(index)); + const tickFont = toFont(optsAtIndex.font); offset = me.getDistanceFromCenterForValue(me.ticks[index].value); - const showLabelBackdrop = resolve([tickOpts.showLabelBackdrop], context, index); - - if (showLabelBackdrop) { + if (optsAtIndex.showLabelBackdrop) { width = ctx.measureText(tick.label).width; - ctx.fillStyle = resolve([tickOpts.backdropColor], context, index); + ctx.fillStyle = optsAtIndex.backdropColor; ctx.fillRect( - -width / 2 - tickOpts.backdropPaddingX, - -offset - tickFont.size / 2 - tickOpts.backdropPaddingY, - width + tickOpts.backdropPaddingX * 2, - tickFont.size + tickOpts.backdropPaddingY * 2 + -width / 2 - optsAtIndex.backdropPaddingX, + -offset - tickFont.size / 2 - optsAtIndex.backdropPaddingY, + width + optsAtIndex.backdropPaddingX * 2, + tickFont.size + optsAtIndex.backdropPaddingY * 2 ); } renderText(ctx, tick.label, 0, -offset, tickFont, { - color: tickOpts.color, + color: optsAtIndex.color, }); }); @@ -565,3 +553,9 @@ RadialLinearScale.defaultRoutes = { 'pointLabels.color': 'color', 'ticks.color': 'color' }; + +RadialLinearScale.descriptors = { + angleLines: { + _fallback: 'gridLines' + } +}; diff --git a/test/fixtures/scale.radialLinear/gridlines-scriptable.js b/test/fixtures/scale.radialLinear/gridlines-scriptable.js index fb1dcd527e4..535433fedaa 100644 --- a/test/fixtures/scale.radialLinear/gridlines-scriptable.js +++ b/test/fixtures/scale.radialLinear/gridlines-scriptable.js @@ -11,10 +11,10 @@ module.exports = { gridLines: { display: true, color: function(context) { - return context.index % 2 === 0 ? 'red' : 'green'; + return context.index % 2 === 0 ? 'green' : 'red'; }, lineWidth: function(context) { - return context.index % 2 === 0 ? 1 : 5; + return context.index % 2 === 0 ? 5 : 1; }, }, angleLines: { diff --git a/test/specs/controller.bar.tests.js b/test/specs/controller.bar.tests.js index b991c6cc445..b69a5819b94 100644 --- a/test/specs/controller.bar.tests.js +++ b/test/specs/controller.bar.tests.js @@ -1381,7 +1381,7 @@ describe('Chart.controllers.bar', function() { var meta = chart.getDatasetMeta(0); var yScale = chart.scales[meta.yAxisID]; - var config = meta.controller._config; + var config = meta.controller.options; var categoryPercentage = config.categoryPercentage; var barPercentage = config.barPercentage; var stacked = yScale.options.stacked; diff --git a/test/specs/core.animations.tests.js b/test/specs/core.animations.tests.js index d7b2c193902..6cb2d3aac4d 100644 --- a/test/specs/core.animations.tests.js +++ b/test/specs/core.animations.tests.js @@ -11,7 +11,7 @@ describe('Chart.animations', function() { } }); expect(anims._properties.get('property1')).toEqual(jasmine.objectContaining({duration: 1000})); - expect(anims._properties.get('property2')).toEqual({duration: 2000}); + expect(anims._properties.get('property2')).toEqual(jasmine.objectContaining({duration: 2000})); }); it('should ignore duplicate definitions from collections', function() { @@ -52,7 +52,7 @@ describe('Chart.animations', function() { }); it('should clone the target options, if those are shared and new options are not', function() { - const chart = {}; + const chart = {options: {}}; const anims = new Chart.Animations(chart, {option: {duration: 200}}); const options = {option: 0, $shared: true}; const target = {options}; @@ -138,7 +138,6 @@ describe('Chart.animations', function() { }, 50); }); - it('should assign final shared options to target after animations complete', function(done) { const chart = { draw: function() {}, diff --git a/test/specs/core.controller.tests.js b/test/specs/core.controller.tests.js index 6d67b9c2cf0..27736973b1f 100644 --- a/test/specs/core.controller.tests.js +++ b/test/specs/core.controller.tests.js @@ -104,7 +104,7 @@ describe('Chart', function() { var options = chart.options; expect(options.font.size).toBe(defaults.font.size); - expect(options.showLine).toBe(defaults.controllers.line.showLine); + expect(options.showLine).toBe(defaults.controllers.line.datasets.showLine); expect(options.spanGaps).toBe(true); expect(options.hover.onHover).toBe(callback); expect(options.hover.mode).toBe('test'); @@ -128,7 +128,7 @@ describe('Chart', function() { var options = chart.options; expect(options.font.size).toBe(defaults.font.size); - expect(options.showLine).toBe(defaults.controllers.line.showLine); + expect(options.showLine).toBe(defaults.controllers.line.datasets.showLine); expect(options.spanGaps).toBe(true); expect(options.hover.onHover).toBe(callback); expect(options.hover.mode).toBe('test'); @@ -162,7 +162,6 @@ describe('Chart', function() { }); var options = chart.options; - expect(options.showLine).toBe(defaults.showLine); expect(options.spanGaps).toBe(false); expect(options.hover.mode).toBe('dataset'); expect(options.plugins.title.position).toBe('bottom'); @@ -1252,7 +1251,7 @@ describe('Chart', function() { options: { responsive: true, scales: { - y: { + yAxis0: { min: 0, max: 10 } @@ -1298,7 +1297,7 @@ describe('Chart', function() { chart.options.plugins.tooltip = newTooltipConfig; chart.update(); - expect(chart.tooltip.options).toEqual(jasmine.objectContaining(newTooltipConfig)); + expect(chart.tooltip.options).toEqualOptions(newTooltipConfig); }); it ('should update the tooltip on update', async function() { diff --git a/test/specs/core.datasetController.tests.js b/test/specs/core.datasetController.tests.js index 2b34a8fa36c..028b3077ffe 100644 --- a/test/specs/core.datasetController.tests.js +++ b/test/specs/core.datasetController.tests.js @@ -674,85 +674,6 @@ describe('Chart.DatasetController', function() { Chart.defaults.borderColor = oldColor; }); - describe('_resolveOptions', function() { - it('should resove names in array notation', function() { - Chart.defaults.elements.line.globalTest = 'global'; - - const chart = acquireChart({ - type: 'line', - data: { - datasets: [{ - data: [1], - datasetTest: 'dataset' - }] - }, - options: { - elements: { - line: { - elementTest: 'element' - } - } - } - }); - - const controller = chart.getDatasetMeta(0).controller; - - expect(controller._resolveOptions( - [ - 'datasetTest', - 'elementTest', - 'globalTest' - ], - {type: 'line'}) - ).toEqual({ - datasetTest: 'dataset', - elementTest: 'element', - globalTest: 'global' - }); - - // Remove test from global defaults - delete Chart.defaults.elements.line.globalTest; - }); - - it('should resove names in object notation', function() { - Chart.defaults.elements.line.global = 'global'; - - const chart = acquireChart({ - type: 'line', - data: { - datasets: [{ - data: [1], - datasetTest: 'dataset' - }] - }, - options: { - elements: { - line: { - element: 'element' - } - } - } - }); - - const controller = chart.getDatasetMeta(0).controller; - - expect(controller._resolveOptions( - { - dataset: 'datasetTest', - element: 'elementTest', - global: 'globalTest'}, - {type: 'line'}) - ).toEqual({ - dataset: 'dataset', - element: 'element', - global: 'global' - }); - - // Remove test from global defaults - delete Chart.defaults.elements.line.global; - }); - }); - describe('resolveDataElementOptions', function() { it('should cache options when possible', function() { const chart = acquireChart({ diff --git a/test/specs/core.plugin.tests.js b/test/specs/core.plugin.tests.js index d38e55619d8..9a70f10a7e8 100644 --- a/test/specs/core.plugin.tests.js +++ b/test/specs/core.plugin.tests.js @@ -13,7 +13,7 @@ describe('Chart.plugins', function() { expect(plugin.hook.calls.count()).toBe(1); expect(plugin.hook.calls.first().args[0]).toBe(chart); expect(plugin.hook.calls.first().args[1]).toBe(args); - expect(plugin.hook.calls.first().args[2]).toEqual({}); + expect(plugin.hook.calls.first().args[2]).toEqualOptions({}); }); it('should call global plugins with arguments', function() { @@ -28,7 +28,7 @@ describe('Chart.plugins', function() { expect(plugin.hook.calls.count()).toBe(1); expect(plugin.hook.calls.first().args[0]).toBe(chart); expect(plugin.hook.calls.first().args[1]).toBe(args); - expect(plugin.hook.calls.first().args[2]).toEqual({}); + expect(plugin.hook.calls.first().args[2]).toEqualOptions({}); Chart.unregister(plugin); }); @@ -181,9 +181,9 @@ describe('Chart.plugins', function() { chart.notifyPlugins('hook', {arg1: 'bla', arg2: 42}); expect(plugin.hook.calls.count()).toBe(3); - expect(plugin.hook.calls.argsFor(0)[2]).toEqual({a: '123'}); - expect(plugin.hook.calls.argsFor(1)[2]).toEqual({a: '123'}); - expect(plugin.hook.calls.argsFor(2)[2]).toEqual({a: '123'}); + expect(plugin.hook.calls.argsFor(0)[2]).toEqualOptions({a: '123'}); + expect(plugin.hook.calls.argsFor(1)[2]).toEqualOptions({a: '123'}); + expect(plugin.hook.calls.argsFor(2)[2]).toEqualOptions({a: '123'}); Chart.unregister(plugin); }); @@ -217,9 +217,9 @@ describe('Chart.plugins', function() { expect(plugins.a.hook).toHaveBeenCalled(); expect(plugins.b.hook).toHaveBeenCalled(); expect(plugins.c.hook).toHaveBeenCalled(); - expect(plugins.a.hook.calls.first().args[2]).toEqual({a: '123'}); - expect(plugins.b.hook.calls.first().args[2]).toEqual({b: '456'}); - expect(plugins.c.hook.calls.first().args[2]).toEqual({c: '789'}); + expect(plugins.a.hook.calls.first().args[2]).toEqualOptions({a: '123'}); + expect(plugins.b.hook.calls.first().args[2]).toEqualOptions({b: '456'}); + expect(plugins.c.hook.calls.first().args[2]).toEqualOptions({c: '789'}); Chart.unregister(plugins.a); }); @@ -274,7 +274,7 @@ describe('Chart.plugins', function() { chart.notifyPlugins('hook'); expect(plugin.hook).toHaveBeenCalled(); - expect(plugin.hook.calls.first().args[2]).toEqual({a: 42}); + expect(plugin.hook.calls.first().args[2]).toEqualOptions({a: 42}); Chart.unregister(plugin); }); @@ -291,7 +291,7 @@ describe('Chart.plugins', function() { chart.notifyPlugins('hook'); expect(plugin.hook).toHaveBeenCalled(); - expect(plugin.hook.calls.first().args[2]).toEqual({a: 'foobar'}); + expect(plugin.hook.calls.first().args[2]).toEqualOptions({a: 'foobar'}); Chart.unregister(plugin); }); @@ -315,7 +315,7 @@ describe('Chart.plugins', function() { chart.notifyPlugins('hook'); expect(plugin.hook).toHaveBeenCalled(); - expect(plugin.hook.calls.first().args[2]).toEqual({foo: 'foo'}); + expect(plugin.hook.calls.first().args[2]).toEqualOptions({foo: 'foo'}); chart.options.plugins.a = {bar: 'bar'}; chart.update(); @@ -324,7 +324,7 @@ describe('Chart.plugins', function() { chart.notifyPlugins('hook'); expect(plugin.hook).toHaveBeenCalled(); - expect(plugin.hook.calls.first().args[2]).toEqual({bar: 'bar'}); + expect(plugin.hook.calls.first().args[2]).toEqualOptions({bar: 'bar'}); }); it('should disable all plugins', function() { diff --git a/test/specs/helpers.config.tests.js b/test/specs/helpers.config.tests.js new file mode 100644 index 00000000000..37213dc5ed4 --- /dev/null +++ b/test/specs/helpers.config.tests.js @@ -0,0 +1,293 @@ +describe('Chart.helpers.config', function() { + const {getHoverColor, _createResolver, _attachContext} = Chart.helpers; + + describe('_createResolver', function() { + it('should resolve to raw values', function() { + const defaults = { + color: 'red', + backgroundColor: 'green', + hoverColor: (ctx, options) => getHoverColor(options.color) + }; + const options = { + color: 'blue' + }; + const resolver = _createResolver([options, defaults]); + expect(resolver.color).toEqual('blue'); + expect(resolver.backgroundColor).toEqual('green'); + expect(resolver.hoverColor).toEqual(defaults.hoverColor); + }); + + it('should resolve to parent scopes', function() { + const defaults = { + root: true, + sub: { + child: true + } + }; + const options = { + child: 'sub default comes before this', + opt: 'opt' + }; + const resolver = _createResolver([options, defaults]); + const sub = resolver.sub; + expect(sub.root).toEqual(true); + expect(sub.child).toEqual(true); + expect(sub.opt).toEqual('opt'); + }); + + it('should follow _fallback', function() { + const defaults = { + interaction: { + mode: 'test', + priority: 'fall' + }, + hover: { + _fallback: 'interaction', + priority: 'main' + } + }; + const options = { + interaction: { + a: 1 + }, + hover: { + b: 2 + } + }; + const resolver = _createResolver([options, defaults]); + expect(resolver.hover).toEqualOptions({ + mode: 'test', + priority: 'main', + a: 1, + b: 2 + }); + }); + + it('should support overriding options', function() { + const defaults = { + option1: 'defaults1', + option2: 'defaults2', + option3: 'defaults3', + }; + const options = { + option1: 'options1', + option2: 'options2' + }; + const overrides = { + option1: 'override1' + }; + const resolver = _createResolver([options, defaults]); + expect(resolver).toEqualOptions({ + option1: 'options1', + option2: 'options2', + option3: 'defaults3' + }); + expect(resolver.override(overrides)).toEqualOptions({ + option1: 'override1', + option2: 'options2', + option3: 'defaults3' + }); + }); + }); + + describe('_attachContext', function() { + it('should resolve to final values', function() { + const defaults = { + color: 'red', + backgroundColor: 'green', + hoverColor: (ctx, options) => getHoverColor(options.color) + }; + const options = { + color: ['white', 'blue'] + }; + const resolver = _createResolver([options, defaults]); + const opts = _attachContext(resolver, {index: 1}); + expect(opts.color).toEqual('blue'); + expect(opts.backgroundColor).toEqual('green'); + expect(opts.hoverColor).toEqual(getHoverColor('blue')); + }); + + it('should thrown on recursion', function() { + const options = { + foo: (ctx, opts) => opts.bar, + bar: (ctx, opts) => opts.xyz, + xyz: (ctx, opts) => opts.foo + }; + const resolver = _createResolver([options]); + const opts = _attachContext(resolver, {test: true}); + expect(function() { + return opts.foo; + }).toThrowError('Recursion detected: foo->bar->xyz->foo'); + }); + + it('should support scriptable options in subscopes', function() { + const defaults = { + elements: { + point: { + backgroundColor: 'red' + } + } + }; + const options = { + elements: { + point: { + borderColor: (ctx, opts) => getHoverColor(opts.backgroundColor) + } + } + }; + const resolver = _createResolver([options, defaults]); + const opts = _attachContext(resolver, {}); + expect(opts.elements.point.borderColor).toEqual(getHoverColor('red')); + expect(opts.elements.point.backgroundColor).toEqual('red'); + }); + + it('same resolver should be usable with multiple contexts', function() { + const defaults = { + animation: { + delay: 10 + } + }; + const options = { + animation: (ctx) => ctx.index === 0 ? {duration: 1000} : {duration: 500} + }; + const resolver = _createResolver([options, defaults]); + const opts1 = _attachContext(resolver, {index: 0}); + const opts2 = _attachContext(resolver, {index: 1}); + + expect(opts1.animation.duration).toEqual(1000); + expect(opts1.animation.delay).toEqual(10); + + expect(opts2.animation.duration).toEqual(500); + expect(opts2.animation.delay).toEqual(10); + }); + + it('should fall back from object returned from scriptable option', function() { + const defaults = { + mainScope: { + main: true, + subScope: { + sub: true + } + } + }; + const options = { + mainScope: (ctx) => ({ + mainTest: ctx.contextValue, + subScope: { + subText: 'a' + } + }) + }; + const opts = _attachContext(_createResolver([options, defaults]), {contextValue: 'test'}); + expect(opts.mainScope).toEqualOptions({ + main: true, + mainTest: 'test', + subScope: { + sub: true, + subText: 'a' + } + }); + }); + + it('should resolve array of non-indexable objects properly', function() { + const defaults = { + label: { + value: 42, + text: (ctx) => ctx.text + }, + labels: { + _fallback: 'label', + _indexable: false + } + }; + + const options = { + labels: [{text: 'a'}, {text: 'b'}, {value: 1}] + }; + const opts = _attachContext(_createResolver([options, defaults]), {text: 'context'}); + expect(opts).toEqualOptions({ + labels: [ + { + text: 'a', + value: 42 + }, + { + text: 'b', + value: 42 + }, + { + text: 'context', + value: 1 + } + ] + }); + }); + + it('should support overriding options', function() { + const options = { + fn1: ctx => ctx.index, + fn2: ctx => ctx.type + }; + const override = { + fn1: ctx => ctx.index * 2 + }; + const opts = _attachContext(_createResolver([options]), {index: 2, type: 'test'}); + expect(opts).toEqualOptions({ + fn1: 2, + fn2: 'test' + }); + expect(opts.override(override)).toEqualOptions({ + fn1: 4, + fn2: 'test' + }); + }); + + it('should support changing context', function() { + const opts = _attachContext(_createResolver([{fn: ctx => ctx.test}]), {test: 1}); + expect(opts.fn).toEqual(1); + expect(opts.setContext({test: 2}).fn).toEqual(2); + expect(opts.fn).toEqual(1); + }); + + describe('_indexable and _scriptable', function() { + it('should default to true', function() { + const options = { + array: [1, 2, 3], + func: (ctx) => ctx.index * 10 + }; + const opts = _attachContext(_createResolver([options]), {index: 1}); + expect(opts.array).toEqual(2); + expect(opts.func).toEqual(10); + }); + + it('should allow false', function() { + const fn = () => 'test'; + const options = { + _indexable: false, + _scriptable: false, + array: [1, 2, 3], + func: fn + }; + const opts = _attachContext(_createResolver([options]), {index: 1}); + expect(opts.array).toEqual([1, 2, 3]); + expect(opts.func).toEqual(fn); + expect(opts.func()).toEqual('test'); + }); + + it('should allow function', function() { + const fn = () => 'test'; + const options = { + _indexable: (prop) => prop !== 'array', + _scriptable: (prop) => prop === 'func', + array: [1, 2, 3], + array2: ['a', 'b', 'c'], + func: fn + }; + const opts = _attachContext(_createResolver([options]), {index: 1}); + expect(opts.array).toEqual([1, 2, 3]); + expect(opts.func).toEqual('test'); + expect(opts.array2).toEqual('b'); + }); + }); + }); +}); diff --git a/test/specs/plugin.legend.tests.js b/test/specs/plugin.legend.tests.js index 92bf482a07d..a5d83694993 100644 --- a/test/specs/plugin.legend.tests.js +++ b/test/specs/plugin.legend.tests.js @@ -620,7 +620,7 @@ describe('Legend block tests', function() { lineWidth: 5, strokeStyle: 'green', pointStyle: 'crossRot', - rotation: undefined, + rotation: 0, datasetIndex: 0 }, { text: 'dataset2', @@ -690,7 +690,7 @@ describe('Legend block tests', function() { lineWidth: 5, strokeStyle: 'green', pointStyle: 'star', - rotation: undefined, + rotation: 0, datasetIndex: 0 }, { text: 'dataset2', @@ -737,7 +737,7 @@ describe('Legend block tests', function() { }); describe('config update', function() { - it ('should update the options', function() { + it('should update the options', function() { var chart = acquireChart({ type: 'line', data: { @@ -761,7 +761,7 @@ describe('Legend block tests', function() { expect(chart.legend.options.display).toBe(false); }); - it ('should update the associated layout item', function() { + it('should update the associated layout item', function() { var chart = acquireChart({ type: 'line', data: {}, @@ -790,7 +790,7 @@ describe('Legend block tests', function() { expect(chart.legend.weight).toBe(42); }); - it ('should remove the legend if the new options are false', function() { + it('should remove the legend if the new options are false', function() { var chart = acquireChart({ type: 'line', data: { @@ -807,7 +807,7 @@ describe('Legend block tests', function() { expect(chart.legend).toBe(undefined); }); - it ('should create the legend if the legend options are changed to exist', function() { + it('should create the legend if the legend options are changed to exist', function() { var chart = acquireChart({ type: 'line', data: { @@ -827,7 +827,7 @@ describe('Legend block tests', function() { chart.options.plugins.legend = {}; chart.update(); expect(chart.legend).not.toBe(undefined); - expect(chart.legend.options).toEqual(jasmine.objectContaining(Chart.defaults.plugins.legend)); + expect(chart.legend.options).toEqualOptions(Chart.defaults.plugins.legend); }); }); diff --git a/test/specs/plugin.title.tests.js b/test/specs/plugin.title.tests.js index 9bcd0e802c7..fb9572380de 100644 --- a/test/specs/plugin.title.tests.js +++ b/test/specs/plugin.title.tests.js @@ -350,7 +350,7 @@ describe('Title block tests', function() { chart.options.plugins.title = {}; chart.update(); expect(chart.titleBlock).not.toBe(undefined); - expect(chart.titleBlock.options).toEqual(jasmine.objectContaining(Chart.defaults.plugins.title)); + expect(chart.titleBlock.options).toEqualOptions(Chart.defaults.plugins.title); }); }); }); diff --git a/test/specs/plugin.tooltip.tests.js b/test/specs/plugin.tooltip.tests.js index ba8f10fe068..c0fa90a1c36 100644 --- a/test/specs/plugin.tooltip.tests.js +++ b/test/specs/plugin.tooltip.tests.js @@ -80,44 +80,44 @@ describe('Plugin.Tooltip', function() { expect(tooltip.yAlign).toEqual('center'); expect(tooltip.options.bodyColor).toEqual('#fff'); - expect(tooltip.options.bodyFont).toEqual(jasmine.objectContaining({ + expect(tooltip.options.bodyFont).toEqualOptions({ family: defaults.font.family, style: defaults.font.style, size: defaults.font.size, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ bodyAlign: 'left', bodySpacing: 2, - })); + }); expect(tooltip.options.titleColor).toEqual('#fff'); - expect(tooltip.options.titleFont).toEqual(jasmine.objectContaining({ + expect(tooltip.options.titleFont).toEqualOptions({ family: defaults.font.family, style: 'bold', size: defaults.font.size, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ titleAlign: 'left', titleSpacing: 2, titleMarginBottom: 6, - })); + }); expect(tooltip.options.footerColor).toEqual('#fff'); - expect(tooltip.options.footerFont).toEqual(jasmine.objectContaining({ + expect(tooltip.options.footerFont).toEqualOptions({ family: defaults.font.family, style: 'bold', size: defaults.font.size, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ footerAlign: 'left', footerSpacing: 2, footerMarginTop: 6, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ // Appearance caretSize: 5, caretPadding: 2, @@ -125,7 +125,7 @@ describe('Plugin.Tooltip', function() { backgroundColor: 'rgba(0,0,0,0.8)', multiKeyBackground: '#fff', displayColors: true - })); + }); expect(tooltip).toEqual(jasmine.objectContaining({ opacity: 1, @@ -245,10 +245,10 @@ describe('Plugin.Tooltip', function() { size: defaults.font.size, })); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ bodyAlign: 'left', bodySpacing: 2, - })); + }); expect(tooltip.options.titleFont).toEqual(jasmine.objectContaining({ family: defaults.font.family, @@ -256,25 +256,25 @@ describe('Plugin.Tooltip', function() { size: defaults.font.size, })); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ titleAlign: 'left', titleSpacing: 2, titleMarginBottom: 6, - })); + }); - expect(tooltip.options.footerFont).toEqual(jasmine.objectContaining({ + expect(tooltip.options.footerFont).toEqualOptions({ family: defaults.font.family, style: 'bold', size: defaults.font.size, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ footerAlign: 'left', footerSpacing: 2, footerMarginTop: 6, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ // Appearance caretSize: 5, caretPadding: 2, @@ -282,7 +282,7 @@ describe('Plugin.Tooltip', function() { backgroundColor: 'rgba(0,0,0,0.8)', multiKeyBackground: '#fff', displayColors: true - })); + }); expect(tooltip.opacity).toEqual(1); expect(tooltip.title).toEqual(['Point 2']); @@ -395,10 +395,10 @@ describe('Plugin.Tooltip', function() { size: defaults.font.size, })); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ bodyAlign: 'left', bodySpacing: 2, - })); + }); expect(tooltip.options.titleFont).toEqual(jasmine.objectContaining({ family: defaults.font.family, @@ -406,10 +406,10 @@ describe('Plugin.Tooltip', function() { size: defaults.font.size, })); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ titleSpacing: 2, titleMarginBottom: 6, - })); + }); expect(tooltip.options.footerFont).toEqual(jasmine.objectContaining({ family: defaults.font.family, @@ -417,20 +417,20 @@ describe('Plugin.Tooltip', function() { size: defaults.font.size, })); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ footerAlign: 'left', footerSpacing: 2, footerMarginTop: 6, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ // Appearance caretSize: 5, caretPadding: 2, cornerRadius: 6, backgroundColor: 'rgba(0,0,0,0.8)', multiKeyBackground: '#fff', - })); + }); expect(tooltip).toEqual(jasmine.objectContaining({ opacity: 1, @@ -470,7 +470,6 @@ describe('Plugin.Tooltip', function() { expect(tooltip.y).toBeCloseToPixel(75); }); - it('Should provide context object to user callbacks', async function() { const chart = window.acquireChart({ type: 'line', @@ -811,10 +810,10 @@ describe('Plugin.Tooltip', function() { // Check and see if tooltip was displayed var tooltip = chart.tooltip; - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ // Positioning caretPadding: 10, - })); + }); }); ['line', 'bar'].forEach(function(type) { @@ -1184,51 +1183,51 @@ describe('Plugin.Tooltip', function() { expect(tooltip.xAlign).toEqual('center'); expect(tooltip.yAlign).toEqual('top'); - expect(tooltip.options.bodyFont).toEqual(jasmine.objectContaining({ + expect(tooltip.options.bodyFont).toEqualOptions({ family: defaults.font.family, style: defaults.font.style, size: defaults.font.size, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ bodyAlign: 'left', bodySpacing: 2, - })); + }); - expect(tooltip.options.titleFont).toEqual(jasmine.objectContaining({ + expect(tooltip.options.titleFont).toEqualOptions({ family: defaults.font.family, style: 'bold', size: defaults.font.size, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ titleAlign: 'left', titleSpacing: 2, titleMarginBottom: 6, - })); + }); - expect(tooltip.options.footerFont).toEqual(jasmine.objectContaining({ + expect(tooltip.options.footerFont).toEqualOptions({ family: defaults.font.family, style: 'bold', size: defaults.font.size, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ footerAlign: 'left', footerSpacing: 2, footerMarginTop: 6, - })); + }); - expect(tooltip.options).toEqual(jasmine.objectContaining({ + expect(tooltip.options).toEqualOptions({ // Appearance caretSize: 5, caretPadding: 2, cornerRadius: 6, backgroundColor: 'rgba(0,0,0,0.8)', multiKeyBackground: '#fff', - })); + }); - expect(tooltip).toEqual(jasmine.objectContaining({ + expect(tooltip).toEqualOptions({ opacity: 1, // Text @@ -1253,7 +1252,7 @@ describe('Plugin.Tooltip', function() { borderColor: defaults.borderColor, backgroundColor: defaults.backgroundColor }] - })); + }); }); describe('text align', function() { diff --git a/types/index.esm.d.ts b/types/index.esm.d.ts index 799be04f838..61d6f2fe278 100644 --- a/types/index.esm.d.ts +++ b/types/index.esm.d.ts @@ -544,7 +544,7 @@ export class DatasetController Date: Sat, 13 Feb 2021 22:17:26 +0200 Subject: [PATCH 2/4] Remove plugin fallback to root options/defaults --- docs/docs/general/options.md | 2 -- src/core/core.plugins.js | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/docs/general/options.md b/docs/docs/general/options.md index a117a72cc4d..05172d8d799 100644 --- a/docs/docs/general/options.md +++ b/docs/docs/general/options.md @@ -60,11 +60,9 @@ Each scope is looked up with `elementType` prefix in the option name first, then * options.plugins[`plugin.id`] * options.controllers[`config.type`].plugins[`plugin.id`] * (options.[`...plugin.additionalOptionScopes`]) -* options * defaults.controllers[`config.type`].plugins[`plugin.id`] * defaults.plugins[`plugin.id`] * (defaults.[`...plugin.additionalOptionScopes`]) -* defaults ## Scriptable Options diff --git a/src/core/core.plugins.js b/src/core/core.plugins.js index af9dca89c35..847ba4a765c 100644 --- a/src/core/core.plugins.js +++ b/src/core/core.plugins.js @@ -168,8 +168,7 @@ function pluginOpts(config, plugin, opts, context) { const keys = [ `controllers.${config.type}.plugins.${id}`, `plugins.${id}`, - ...plugin.additionalOptionScopes || [], - '' + ...plugin.additionalOptionScopes || [] ]; const scopes = config.getOptionScopes(opts || {}, keys); return config.createResolver(scopes, context); From f76c9292d48cdf1911caa9be3021ddbd642140a9 Mon Sep 17 00:00:00 2001 From: kurkle Date: Sat, 13 Feb 2021 22:46:44 +0200 Subject: [PATCH 3/4] Update core plugins, reduntant font fallbacks --- src/core/core.scale.js | 2 +- src/plugins/plugin.legend.js | 12 +++++++----- src/plugins/plugin.title.js | 8 +++++--- src/plugins/plugin.tooltip.js | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/core/core.scale.js b/src/core/core.scale.js index a1eab6cdf82..73b95b8c62d 100644 --- a/src/core/core.scale.js +++ b/src/core/core.scale.js @@ -1667,7 +1667,7 @@ export default class Scale extends Element { return; } - const scaleLabelFont = toFont(scaleLabel.font, me.chart.options.font); + const scaleLabelFont = toFont(scaleLabel.font); const scaleLabelPadding = toPadding(scaleLabel.padding); const halfLineHeight = scaleLabelFont.lineHeight / 2; const scaleLabelAlign = scaleLabel.align; diff --git a/src/plugins/plugin.legend.js b/src/plugins/plugin.legend.js index 342cd615a54..60b58f07c7a 100644 --- a/src/plugins/plugin.legend.js +++ b/src/plugins/plugin.legend.js @@ -128,7 +128,7 @@ export class Legend extends Element { } const labelOpts = options.labels; - const labelFont = toFont(labelOpts.font, me.chart.options.font); + const labelFont = toFont(labelOpts.font); const fontSize = labelFont.size; const titleHeight = me._computeTitleHeight(); const {boxWidth, itemHeight} = getBoxSize(labelOpts, fontSize); @@ -241,7 +241,7 @@ export class Legend extends Element { const {align, labels: labelOpts} = opts; const defaultColor = defaults.color; const rtlHelper = getRtlAdapter(opts.rtl, me.left, me.width); - const labelFont = toFont(labelOpts.font, me.chart.options.font); + const labelFont = toFont(labelOpts.font); const {color: fontColor, padding} = labelOpts; const fontSize = labelFont.size; let cursor; @@ -378,7 +378,7 @@ export class Legend extends Element { const me = this; const opts = me.options; const titleOpts = opts.title; - const titleFont = toFont(titleOpts.font, me.chart.options.font); + const titleFont = toFont(titleOpts.font); const titlePadding = toPadding(titleOpts.padding); if (!titleOpts.display) { @@ -427,7 +427,7 @@ export class Legend extends Element { */ _computeTitleHeight() { const titleOpts = this.options.title; - const titleFont = toFont(titleOpts.font, this.chart.options.font); + const titleFont = toFont(titleOpts.font); const titlePadding = toPadding(titleOpts.padding); return titleOpts.display ? titleFont.lineHeight + titlePadding.height : 0; } @@ -621,5 +621,7 @@ export default { labels: { _scriptable: false, } - } + }, + + additionalOptionScopes: [''] }; diff --git a/src/plugins/plugin.title.js b/src/plugins/plugin.title.js index 6543f5b29cc..cfb0b1a2258 100644 --- a/src/plugins/plugin.title.js +++ b/src/plugins/plugin.title.js @@ -43,7 +43,7 @@ export class Title extends Element { const lineCount = isArray(opts.text) ? opts.text.length : 1; me._padding = toPadding(opts.padding); - const textSize = lineCount * toFont(opts.font, me.chart.options.font).lineHeight + me._padding.height; + const textSize = lineCount * toFont(opts.font).lineHeight + me._padding.height; if (me.isHorizontal()) { me.height = textSize; @@ -91,7 +91,7 @@ export class Title extends Element { return; } - const fontOpts = toFont(opts.font, me.chart.options.font); + const fontOpts = toFont(opts.font); const lineHeight = fontOpts.lineHeight; const offset = lineHeight / 2 + me._padding.top; const {titleX, titleY, maxWidth, rotation} = me._drawArgs(offset); @@ -179,5 +179,7 @@ export default { defaultRoutes: { color: 'color' - } + }, + + additionalOptionScopes: [''] }; diff --git a/src/plugins/plugin.tooltip.js b/src/plugins/plugin.tooltip.js index 41755641a24..158f4a3345f 100644 --- a/src/plugins/plugin.tooltip.js +++ b/src/plugins/plugin.tooltip.js @@ -1206,5 +1206,5 @@ export default { } }, - additionalOptionScopes: ['interaction'] + additionalOptionScopes: ['interaction', ''] }; From fae70dfc64c059cf2ad6b4a73af61264c702f143 Mon Sep 17 00:00:00 2001 From: Jukka Kurkela Date: Mon, 15 Feb 2021 20:33:59 +0200 Subject: [PATCH 4/4] Add some notes --- docs/docs/general/options.md | 2 ++ src/plugins/plugin.legend.js | 1 + src/plugins/plugin.title.js | 1 + src/plugins/plugin.tooltip.js | 1 + 4 files changed, 5 insertions(+) diff --git a/docs/docs/general/options.md b/docs/docs/general/options.md index 05172d8d799..e5906a2fede 100644 --- a/docs/docs/general/options.md +++ b/docs/docs/general/options.md @@ -57,6 +57,8 @@ Each scope is looked up with `elementType` prefix in the option name first, then ### Plugin options +A plugin can provide `additionalOptionScopes` array of paths to additionally look for its options in. For root scope, use empty string: `''`. Most core plugins also take options from root scope. + * options.plugins[`plugin.id`] * options.controllers[`config.type`].plugins[`plugin.id`] * (options.[`...plugin.additionalOptionScopes`]) diff --git a/src/plugins/plugin.legend.js b/src/plugins/plugin.legend.js index 60b58f07c7a..ffa8dfc3d45 100644 --- a/src/plugins/plugin.legend.js +++ b/src/plugins/plugin.legend.js @@ -623,5 +623,6 @@ export default { } }, + // For easier configuration, resolve additionally from root of options and defaults. additionalOptionScopes: [''] }; diff --git a/src/plugins/plugin.title.js b/src/plugins/plugin.title.js index cfb0b1a2258..04e6917969d 100644 --- a/src/plugins/plugin.title.js +++ b/src/plugins/plugin.title.js @@ -181,5 +181,6 @@ export default { color: 'color' }, + // For easier configuration, resolve additionally from root of options and defaults. additionalOptionScopes: [''] }; diff --git a/src/plugins/plugin.tooltip.js b/src/plugins/plugin.tooltip.js index 158f4a3345f..7edffa7f27e 100644 --- a/src/plugins/plugin.tooltip.js +++ b/src/plugins/plugin.tooltip.js @@ -1206,5 +1206,6 @@ export default { } }, + // For easier configuration, resolve additionally from `interaction` and root of options and defaults. additionalOptionScopes: ['interaction', ''] };