diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index bdbc82804..ea6599949 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -144,6 +144,7 @@ module.exports = { 'box/disclosure', 'box/canvas', 'box/image', + 'box/initAnim', 'box/gradient', ] }, @@ -196,6 +197,7 @@ module.exports = { 'point/combined', 'point/outsideChartArea', 'point/shadow', + 'point/initAnim', ] }, { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3d9bd1b9a..d05fe0ca8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -63,7 +63,36 @@ The following options apply to all annotations unless they are overwritten on a | Name | Type | [Scriptable](options.md#scriptable-options) | Default | Notes | ---- | ---- | :----: | ---- | ---- -| `drawTime` | `string` | Yes | `'afterDatasetsDraw'` | See [drawTime](options.md#draw-time). +| `drawTime` | `string` | Yes | `'afterDatasetsDraw'` | See [drawTime](options#draw-time). +| `init` | `boolean` | [See initial animation](#initial-animation) | `false` | Enable the animation to the annotations when they are drawing at chart initialization + +### Initial animation + +The `init` option is scriptable but it doesn't get the [options context](./options#option-context) as argument but a specific context because the element has not been initialized yet, when the callback is invoked. + +This is the signature of the scriptable option: + +```javascript +({chart, properties, options}) => void | boolean | AnnotationBoxModel +``` + +where the properties is the element model + +```javascript +{ + x: number, + y: number, + x2: number, + y2: number, + centerX: number, + centerY: number, + width: number, + height: number, + radius?: number +} +``` + +which can be used in the callback to return an object with the initial values of the element, to provide own initial animation. ## Events diff --git a/docs/guide/types/_commonOptions.md b/docs/guide/types/_commonOptions.md index 39159c2da..4a9bbaf92 100644 --- a/docs/guide/types/_commonOptions.md +++ b/docs/guide/types/_commonOptions.md @@ -12,6 +12,7 @@ The following options are available for all annotations. | [`borderShadowColor`](#styling) | [`Color`](../options.md#color) | Yes | `'transparent'` | [`display`](#general) | `boolean` | Yes | `true` | [`drawTime`](#general) | `string` | Yes | `'afterDatasetsDraw'` +| [`init`](../configuration.html#common) | `boolean` | [See initial animation](../configuration.html#initial-animation) | `undefined` | [`id`](#general) | `string` | No | `undefined` | [`shadowBlur`](#styling) | `number` | Yes | `0` | [`shadowOffsetX`](#styling) | `number` | Yes | `0` diff --git a/docs/samples/box/initAnim.md b/docs/samples/box/initAnim.md new file mode 100644 index 000000000..965894815 --- /dev/null +++ b/docs/samples/box/initAnim.md @@ -0,0 +1,120 @@ +# Initial animations + +```js chart-editor +// +const DATA_COUNT = 12; +const MIN = 10; +const MAX = 100; + +const numberCfg = {count: DATA_COUNT, min: MIN, max: MAX}; + +const data = { + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + datasets: [{ + data: Utils.numbers(numberCfg) + }] +}; +// + +// +const annotation1 = { + type: 'box', + backgroundColor: 'rgba(0, 150, 0, 0.2)', + borderColor: 'rgba(0, 150, 0)', + borderRadius: 4, + borderWidth: 1, + init: true, + label: { + display: true, + content: 'Fade' + }, + xMax: 6.5, + xMin: 4.5, + yMax: 60, + yMin: 40 +}; +// + +// +const annotation2 = { + type: 'box', + backgroundColor: 'rgba(0, 150, 0, 0.2)', + borderColor: 'rgba(0, 150, 0)', + borderRadius: 4, + borderWidth: 1, + init: () => ({y: 0, y2: 0}), + label: { + display: true, + content: 'Flyin from top' + }, + xMax: 2.5, + xMin: 0.5, + yMax: 30, + yMin: 10 +}; +// + +// +const annotation3 = { + type: 'box', + backgroundColor: 'rgba(0, 150, 0, 0.2)', + borderColor: 'rgba(0, 150, 0)', + borderRadius: 4, + borderWidth: 1, + init: () => ({x: 0, x2: 0}), + label: { + display: true, + content: 'Flyin from left' + }, + xMax: 10.5, + xMin: 8.5, + yMax: 90, + yMin: 70 +}; +// + +/* */ +const config = { + type: 'line', + data, + options: { + scales: { + y: { + beginAtZero: true, + max: 100, + min: 0 + } + }, + plugins: { + annotation: { + common: { + drawTime: 'afterDraw' + }, + annotations: { + annotation1, + annotation2, + annotation3 + } + } + } + } +}; +/* */ + +const actions = [ + { + name: 'Randomize', + handler: function(chart) { + chart.data.datasets.forEach(function(dataset, i) { + dataset.data = dataset.data.map(() => Utils.rand(MIN, MAX)); + }); + chart.update(); + } + } +]; + +module.exports = { + actions: actions, + config: config, +}; +``` diff --git a/docs/samples/point/initAnim.md b/docs/samples/point/initAnim.md new file mode 100644 index 000000000..262c1d45d --- /dev/null +++ b/docs/samples/point/initAnim.md @@ -0,0 +1,156 @@ +# Initial animations + +```js chart-editor +// +const DATA_COUNT = 12; +const MIN = 10; +const MAX = 100; + +const numberCfg = {count: DATA_COUNT, min: MIN, max: MAX}; + +const data = { + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + datasets: [{ + data: Utils.numbers(numberCfg) + }] +}; +// + +// +const annotation1 = { + type: 'point', + backgroundColor: 'rgba(0, 150, 0, 0.2)', + borderColor: 'rgba(0, 150, 0)', + borderRadius: 4, + borderWidth: 1, + init: true, + label: { + display: true, + content: 'Fade', + textAlign: 'center' + }, + radius: 40, + xMax: 6.5, + xMin: 4.5, + yMax: 60, + yMin: 40 +}; +const labelAnnotation1 = { + type: 'label', + init: true, + content: 'Fade', + xMax: 6.5, + xMin: 4.5, + yMax: 60, + yMin: 40 +}; +// + +// +const annotation2 = { + type: 'point', + backgroundColor: 'rgba(0, 150, 0, 0.2)', + borderColor: 'rgba(0, 150, 0)', + borderRadius: 4, + borderWidth: 1, + init: () => ({centerY: 0}), + label: { + display: true, + content: 'Flyin from top', + textAlign: 'center' + }, + radius: 40, + xMax: 2.5, + xMin: 0.5, + yMax: 30, + yMin: 10 +}; +const labelAnnotation2 = { + type: 'label', + init: () => ({y: 0, y2: 0, width: 0, height: 0}), + content: 'Flyin from top', + xMax: 2.5, + xMin: 0.5, + yMax: 30, + yMin: 10 +}; +// + +// +const annotation3 = { + type: 'point', + backgroundColor: 'rgba(0, 150, 0, 0.2)', + borderColor: 'rgba(0, 150, 0)', + borderRadius: 4, + borderWidth: 1, + init: () => ({centerX: 0}), + label: { + display: true, + content: 'Flyin from left', + textAlign: 'center' + }, + radius: 40, + xMax: 10.5, + xMin: 8.5, + yMax: 90, + yMin: 70 +}; +const labelAnnotation3 = { + type: 'label', + init: () => ({x: 0, x2: 0, width: 0, height: 0}), + content: 'Flyin from left', + xMax: 10.5, + xMin: 8.5, + yMax: 90, + yMin: 70 +}; +// + +/* */ +const config = { + type: 'line', + data, + options: { + scales: { + y: { + beginAtZero: true, + max: 100, + min: 0 + } + }, + plugins: { + annotation: { + common: { + drawTime: 'afterDraw' + }, + annotations: { + annotation1, + labelAnnotation1, + annotation2, + labelAnnotation2, + annotation3, + labelAnnotation3 + } + } + } + } +}; +/* */ + +const actions = [ + { + name: 'Randomize', + handler: function(chart) { + chart.data.datasets.forEach(function(dataset, i) { + dataset.data = dataset.data.map(() => Utils.rand(MIN, MAX)); + }); + chart.update(); + } + } +]; + +module.exports = { + actions: actions, + config: config, +}; +``` diff --git a/src/annotation.js b/src/annotation.js index 5f26c7942..11c4687a5 100644 --- a/src/annotation.js +++ b/src/annotation.js @@ -120,6 +120,7 @@ export default { }, common: { drawTime: 'afterDatasetsDraw', + init: false, label: { } } @@ -127,7 +128,7 @@ export default { descriptors: { _indexable: false, - _scriptable: (prop) => !hooks.includes(prop), + _scriptable: (prop) => !hooks.includes(prop) && prop !== 'init', annotations: { _allKeys: false, _fallback: (prop, opts) => `elements.${annotationTypes[resolveType(opts.type)].id}` diff --git a/src/elements.js b/src/elements.js index d4df1b3db..fcb4926f5 100644 --- a/src/elements.js +++ b/src/elements.js @@ -56,7 +56,7 @@ export function updateElements(chart, state, options, mode) { properties.skip = toSkip(properties); if ('elements' in properties) { - updateSubElements(element, properties, resolver, animations); + updateSubElements(element, properties.elements, resolver, animations); // Remove the sub-element definitions from properties, so the actual elements // are not overwritten by their definitions delete properties.elements; @@ -70,6 +70,7 @@ export function updateElements(chart, state, options, mode) { Object.assign(element, properties); } + Object.assign(element, properties.initProperties); properties.options = resolveAnnotationOptions(resolver); animations.update(element, properties); @@ -87,13 +88,13 @@ function resolveAnimations(chart, animOpts, mode) { return new Animations(chart, animOpts); } -function updateSubElements(mainElement, {elements, initProperties}, resolver, animations) { +function updateSubElements(mainElement, elements, resolver, animations) { const subElements = mainElement.elements || (mainElement.elements = []); subElements.length = elements.length; for (let i = 0; i < elements.length; i++) { const definition = elements[i]; const properties = definition.properties; - const subElement = getOrCreateElement(subElements, i, definition.type, initProperties); + const subElement = getOrCreateElement(subElements, i, definition.type, definition.initProperties); const subResolver = resolver[definition.optionScope].override(definition); properties.options = resolveAnnotationOptions(subResolver); animations.update(subElement, properties); @@ -105,9 +106,7 @@ function getOrCreateElement(elements, index, type, initProperties) { let element = elements[index]; if (!element || !(element instanceof elementClass)) { element = elements[index] = new elementClass(); - if (isObject(initProperties)) { - Object.assign(element, initProperties); - } + Object.assign(element, initProperties); } return element; } diff --git a/src/helpers/helpers.canvas.js b/src/helpers/helpers.canvas.js index a8c7525ac..436902b8b 100644 --- a/src/helpers/helpers.canvas.js +++ b/src/helpers/helpers.canvas.js @@ -1,8 +1,9 @@ -import {addRoundedRectPath, isArray, isNumber, toFont, toTRBLCorners, toRadians} from 'chart.js/helpers'; +import {addRoundedRectPath, isArray, isNumber, toFont, toTRBLCorners, toRadians, PI, TAU, HALF_PI, QUARTER_PI, TWO_THIRDS_PI, RAD_PER_DEG} from 'chart.js/helpers'; import {clampAll, clamp} from './helpers.core'; import {calculateTextAlignment, getSize} from './helpers.options'; const widthCache = new Map(); +const notRadius = (radius) => isNaN(radius) || radius <= 0; const fontsKey = (fonts) => fonts.reduce(function(prev, item) { prev += item.string; return prev; @@ -11,6 +12,7 @@ const fontsKey = (fonts) => fonts.reduce(function(prev, item) { /** * @typedef { import('chart.js').Point } Point * @typedef { import('../../types/label').CoreLabelOptions } CoreLabelOptions + * @typedef { import('../../types/options').PointAnnotationOptions } PointAnnotationOptions */ /** @@ -159,6 +161,126 @@ function setTextStrokeStyle(ctx, options) { } } +/** + * @param {CanvasRenderingContext2D} ctx + * @param {{radius: number, options: PointAnnotationOptions}} element + * @param {number} x + * @param {number} y + */ +export function drawPoint(ctx, element, x, y) { + const {radius, options} = element; + const style = options.pointStyle; + const rotation = options.rotation; + let rad = (rotation || 0) * RAD_PER_DEG; + + if (isImageOrCanvas(style)) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(rad); + ctx.drawImage(style, -style.width / 2, -style.height / 2, style.width, style.height); + ctx.restore(); + return; + } + if (notRadius(radius)) { + return; + } + drawPointStyle(ctx, {x, y, radius, rotation, style, rad}); +} + +function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) { + let xOffset, yOffset, size, cornerRadius; + ctx.beginPath(); + + switch (style) { + // Default includes circle + default: + ctx.arc(x, y, radius, 0, TAU); + ctx.closePath(); + break; + case 'triangle': + ctx.moveTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); + rad += TWO_THIRDS_PI; + ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); + rad += TWO_THIRDS_PI; + ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); + ctx.closePath(); + break; + case 'rectRounded': + // NOTE: the rounded rect implementation changed to use `arc` instead of + // `quadraticCurveTo` since it generates better results when rect is + // almost a circle. 0.516 (instead of 0.5) produces results with visually + // closer proportion to the previous impl and it is inscribed in the + // circle with `radius`. For more details, see the following PRs: + // https://github.com/chartjs/Chart.js/issues/5597 + // https://github.com/chartjs/Chart.js/issues/5858 + cornerRadius = radius * 0.516; + size = radius - cornerRadius; + xOffset = Math.cos(rad + QUARTER_PI) * size; + yOffset = Math.sin(rad + QUARTER_PI) * size; + ctx.arc(x - xOffset, y - yOffset, cornerRadius, rad - PI, rad - HALF_PI); + ctx.arc(x + yOffset, y - xOffset, cornerRadius, rad - HALF_PI, rad); + ctx.arc(x + xOffset, y + yOffset, cornerRadius, rad, rad + HALF_PI); + ctx.arc(x - yOffset, y + xOffset, cornerRadius, rad + HALF_PI, rad + PI); + ctx.closePath(); + break; + case 'rect': + if (!rotation) { + size = Math.SQRT1_2 * radius; + ctx.rect(x - size, y - size, 2 * size, 2 * size); + break; + } + rad += QUARTER_PI; + /* falls through */ + case 'rectRot': + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + yOffset, y - xOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.lineTo(x - yOffset, y + xOffset); + ctx.closePath(); + break; + case 'crossRot': + rad += QUARTER_PI; + /* falls through */ + case 'cross': + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.moveTo(x + yOffset, y - xOffset); + ctx.lineTo(x - yOffset, y + xOffset); + break; + case 'star': + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.moveTo(x + yOffset, y - xOffset); + ctx.lineTo(x - yOffset, y + xOffset); + rad += QUARTER_PI; + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.moveTo(x + yOffset, y - xOffset); + ctx.lineTo(x - yOffset, y + xOffset); + break; + case 'line': + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + break; + case 'dash': + ctx.moveTo(x, y); + ctx.lineTo(x + Math.cos(rad) * radius, y + Math.sin(rad) * radius); + break; + } + + ctx.fill(); +} + function calculateLabelSize(ctx, lines, fonts, strokeWidth) { ctx.save(); const count = lines.length; diff --git a/src/helpers/helpers.chart.js b/src/helpers/helpers.chart.js index b94780667..0d976604d 100644 --- a/src/helpers/helpers.chart.js +++ b/src/helpers/helpers.chart.js @@ -1,6 +1,6 @@ import {isFinite, toPadding} from 'chart.js/helpers'; import {measureLabelSize} from './helpers.canvas'; -import {isBoundToPoint, getRelativePosition, toPosition} from './helpers.options'; +import {isBoundToPoint, getRelativePosition, toPosition, initAnimationProperties} from './helpers.options'; const limitedLineScale = { xScaleID: {min: 'xMin', max: 'xMax', start: 'left', end: 'right', startProp: 'x', endProp: 'x2'}, @@ -147,7 +147,8 @@ export function resolvePointProperties(chart, options) { centerX: adjustCenterX, centerY: adjustCenterY, width: size, - height: size + height: size, + radius }; } return getChartCircle(chart, options); @@ -173,17 +174,18 @@ export function resolveLineProperties(chart, options) { /** * @param {Chart} chart * @param {CoreAnnotationOptions} options + * @param {boolean} [centerBased=false] * @returns {AnnotationBoxModel} */ -export function resolveBoxAndLabelProperties(chart, options) { +export function resolveBoxAndLabelProperties(chart, options, centerBased) { const properties = resolveBoxProperties(chart, options); - const {x, y} = properties; + properties.initProperties = initAnimationProperties(chart, properties, options, centerBased); properties.elements = [{ type: 'label', optionScope: 'label', - properties: resolveLabelElementProperties(chart, properties, options) + properties: resolveLabelElementProperties(chart, properties, options), + initProperties: properties.initProperties }]; - properties.initProperties = {x, y}; return properties; } @@ -197,6 +199,7 @@ function getChartCircle(chart, options) { y2: point.y + options.radius + options.yAdjust, centerX: point.x + options.xAdjust, centerY: point.y + options.yAdjust, + radius: options.radius, width: size, height: size }; diff --git a/src/helpers/helpers.options.js b/src/helpers/helpers.options.js index 49e128c6f..8fffd084d 100644 --- a/src/helpers/helpers.options.js +++ b/src/helpers/helpers.options.js @@ -1,4 +1,4 @@ -import {isObject, isFunction, valueOrDefault, defined} from 'chart.js/helpers'; +import {isObject, isFunction, valueOrDefault, defined, callback} from 'chart.js/helpers'; import {clamp} from './helpers.core'; const isPercentString = (s) => typeof s === 'string' && s.endsWith('%'); @@ -6,6 +6,8 @@ const toPercent = (s) => parseFloat(s) / 100; const toPositivePercent = (s) => clamp(toPercent(s), 0, 1); /** + * @typedef { import("chart.js").Chart } Chart + * @typedef { import('../../types/element').AnnotationBoxModel } AnnotationBoxModel * @typedef { import('../../types/options').AnnotationPointCoordinates } AnnotationPointCoordinates * @typedef { import('../../types/label').CoreLabelOptions } CoreLabelOptions * @typedef { import('../../types/label').LabelPositionObject } LabelPositionObject @@ -87,6 +89,23 @@ export function isBoundToPoint(options) { return options && (defined(options.xValue) || defined(options.yValue)); } +/** + * @param {Chart} chart + * @param {AnnotationBoxModel} properties + * @param {CoreAnnotationOptions} options + * @param {boolean} [centerBased=false] + * @returns {AnnotationBoxModel} + */ +export function initAnimationProperties(chart, properties, options, centerBased = false) { + const initAnim = options.init; + if (!initAnim) { + return; + } else if (initAnim === true) { + return applyDefault(properties, centerBased); + } + return checkCallbackResult(properties, centerBased, callback(initAnim, [{chart, properties, options}])); +} + /** * @param {Object} options * @param {Array} hooks @@ -105,3 +124,18 @@ export function loadHooks(options, hooks, hooksContainer) { }); return activated; } + +function applyDefault({centerX, centerY}, centerBased) { + if (centerBased) { + return {centerX, centerY, radius: 0, width: 0, height: 0}; + } + return {x: centerX, y: centerY, x2: centerX, y2: centerY, width: 0, height: 0}; +} + +function checkCallbackResult(properties, centerBased, result) { + if (result === true) { + return applyDefault(properties, centerBased); + } else if (isObject(result)) { + return result; + } +} diff --git a/src/types/box.js b/src/types/box.js index 5dff8e8fd..66d02ae95 100644 --- a/src/types/box.js +++ b/src/types/box.js @@ -42,6 +42,7 @@ BoxAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + init: undefined, label: { backgroundColor: 'transparent', borderWidth: 0, diff --git a/src/types/ellipse.js b/src/types/ellipse.js index d27ac1fa6..0721f1ee2 100644 --- a/src/types/ellipse.js +++ b/src/types/ellipse.js @@ -24,7 +24,6 @@ export default class EllipseAnnotation extends Element { draw(ctx) { const {width, height, centerX, centerY, options} = this; - ctx.save(); translate(ctx, this.getCenterPoint(), options.rotation); setShadowStyle(ctx, this.options); @@ -45,7 +44,7 @@ export default class EllipseAnnotation extends Element { } resolveElementProperties(chart, options) { - return resolveBoxAndLabelProperties(chart, options); + return resolveBoxAndLabelProperties(chart, options, true); } } @@ -60,6 +59,7 @@ EllipseAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + init: undefined, label: Object.assign({}, BoxAnnotation.defaults.label), rotation: 0, shadowBlur: 0, diff --git a/src/types/label.js b/src/types/label.js index c2f32705d..f1c03ce2a 100644 --- a/src/types/label.js +++ b/src/types/label.js @@ -1,5 +1,5 @@ import {Element} from 'chart.js'; -import {drawBox, drawLabel, measureLabelSize, getChartPoint, toPosition, setBorderStyle, getSize, inBoxRange, isBoundToPoint, resolveBoxProperties, getRelativePosition, translate, rotated, getElementCenterPoint} from '../helpers'; +import {drawBox, drawLabel, measureLabelSize, getChartPoint, toPosition, setBorderStyle, getSize, inBoxRange, isBoundToPoint, resolveBoxProperties, getRelativePosition, translate, rotated, getElementCenterPoint, initAnimationProperties} from '../helpers'; import {toPadding, toRadians, distanceBetweenPoints, defined} from 'chart.js/helpers'; const positions = ['left', 'bottom', 'top', 'right']; @@ -41,6 +41,7 @@ export default class LabelAnnotation extends Element { const labelSize = measureLabelSize(chart.ctx, options); const boxSize = measureRect(point, labelSize, options, padding); return { + initProperties: initAnimationProperties(chart, boxSize, options), pointX: point.x, pointY: point.y, ...boxSize, @@ -86,6 +87,7 @@ LabelAnnotation.defaults = { weight: undefined }, height: undefined, + init: undefined, opacity: undefined, padding: 6, position: 'center', diff --git a/src/types/line.js b/src/types/line.js index d24f1fa9d..ba0aaf874 100644 --- a/src/types/line.js +++ b/src/types/line.js @@ -1,6 +1,6 @@ import {Element} from 'chart.js'; import {PI, toRadians, toDegrees, toPadding, distanceBetweenPoints} from 'chart.js/helpers'; -import {EPSILON, clamp, rotated, measureLabelSize, getRelativePosition, setBorderStyle, setShadowStyle, getElementCenterPoint, toPosition, getSize, resolveLineProperties} from '../helpers'; +import {EPSILON, clamp, rotated, measureLabelSize, getRelativePosition, setBorderStyle, setShadowStyle, getElementCenterPoint, toPosition, getSize, resolveLineProperties, initAnimationProperties} from '../helpers'; import LabelAnnotation from './label'; const pointInLine = (p1, p2, t) => ({x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y)}); @@ -82,7 +82,7 @@ export default class LineAnnotation extends Element { : {x, y, x2, y2, width: Math.abs(x2 - x), height: Math.abs(y2 - y)}; properties.centerX = (x2 + x) / 2; properties.centerY = (y2 + y) / 2; - + properties.initProperties = initAnimationProperties(chart, properties, options); if (options.curve) { const p1 = {x: properties.x, y: properties.y}; const p2 = {x: properties.x2, y: properties.y2}; @@ -95,7 +95,8 @@ export default class LineAnnotation extends Element { properties.elements = [{ type: 'label', optionScope: 'label', - properties: labelProperties + properties: labelProperties, + initProperties: properties.initProperties }]; return properties; } @@ -140,6 +141,7 @@ LineAnnotation.defaults = { }, display: true, endValue: undefined, + init: undefined, label: { backgroundColor: 'rgba(0,0,0,0.8)', backgroundShadowColor: 'transparent', diff --git a/src/types/point.js b/src/types/point.js index 97c0dd039..696ddf4d8 100644 --- a/src/types/point.js +++ b/src/types/point.js @@ -1,6 +1,5 @@ import {Element} from 'chart.js'; -import {drawPoint} from 'chart.js/helpers'; -import {inPointRange, getElementCenterPoint, resolvePointProperties, setBorderStyle, setShadowStyle, isImageOrCanvas} from '../helpers'; +import {inPointRange, getElementCenterPoint, resolvePointProperties, setBorderStyle, setShadowStyle, isImageOrCanvas, initAnimationProperties, drawPoint} from '../helpers'; export default class PointAnnotation extends Element { @@ -29,8 +28,7 @@ export default class PointAnnotation extends Element { ctx.fillStyle = options.backgroundColor; setShadowStyle(ctx, options); const stroke = setBorderStyle(ctx, options); - options.borderWidth = 0; - drawPoint(ctx, options, this.centerX, this.centerY); + drawPoint(ctx, this, this.centerX, this.centerY); if (stroke && !isImageOrCanvas(options.pointStyle)) { ctx.shadowColor = options.borderShadowColor; ctx.stroke(); @@ -40,7 +38,9 @@ export default class PointAnnotation extends Element { } resolveElementProperties(chart, options) { - return resolvePointProperties(chart, options); + const properties = resolvePointProperties(chart, options); + properties.initProperties = initAnimationProperties(chart, properties, options, true); + return properties; } } @@ -54,6 +54,7 @@ PointAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + init: undefined, pointStyle: 'circle', radius: 10, rotation: 0, diff --git a/src/types/polygon.js b/src/types/polygon.js index bcf08e92f..2d622a7c5 100644 --- a/src/types/polygon.js +++ b/src/types/polygon.js @@ -1,6 +1,6 @@ import {Element} from 'chart.js'; import {PI, RAD_PER_DEG, toRadians} from 'chart.js/helpers'; -import {setBorderStyle, resolvePointProperties, getElementCenterPoint, setShadowStyle, rotated} from '../helpers'; +import {setBorderStyle, resolvePointProperties, getElementCenterPoint, setShadowStyle, rotated, initAnimationProperties} from '../helpers'; export default class PolygonAnnotation extends Element { @@ -47,16 +47,16 @@ export default class PolygonAnnotation extends Element { resolveElementProperties(chart, options) { const properties = resolvePointProperties(chart, options); - const {x, y} = properties; const {sides, rotation} = options; const elements = []; const angle = (2 * PI) / sides; let rad = rotation * RAD_PER_DEG; for (let i = 0; i < sides; i++, rad += angle) { - elements.push(buildPointElement(properties, options, rad)); + const elProps = buildPointElement(properties, options, rad); + elProps.initProperties = initAnimationProperties(chart, properties, options); + elements.push(elProps); } properties.elements = elements; - properties.initProperties = {x, y}; return properties; } } @@ -73,6 +73,7 @@ PolygonAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + init: undefined, point: { radius: 0 }, diff --git a/test/fixtures/box/initAnimation.js b/test/fixtures/box/initAnimation.js new file mode 100644 index 000000000..19ac0721a --- /dev/null +++ b/test/fixtures/box/initAnimation.js @@ -0,0 +1,105 @@ +module.exports = { + tolerance: 0.0075, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'] + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + common: { + init: () => true, + }, + annotations: { + box1: { + type: 'box', + init: true, + xMin: 0.5, + xMax: 2.5, + yMin: 8, + yMax: 13, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgb(255, 99, 132)', + borderWidth: 1, + label: { + display: true, + content: 'true' + } + }, + box2: { + type: 'box', + xMin: 'May', + xMax: 'July', + yMin: 11, + yMax: 15, + init: () => undefined, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + label: { + display: true, + content: 'callback undef' + } + }, + box3: { + type: 'box', + xMin: 0.5, + xMax: 'May', + yMin: 16, + yMax: 20, + init: () => true, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + label: { + display: true, + content: 'callback' + } + }, + box4: { + type: 'box', + xMin: 0.5, + xMax: 'May', + yMin: 0.5, + yMax: 4, + init: () => ({y: 0, y2: 0}), + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + label: { + display: true, + content: 'callback object' + } + }, + box5: { + type: 'box', + xMin: 'April', + xMax: 'July', + yMin: 5, + yMax: 9, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + label: { + display: true, + content: 'fallback' + } + } + } + } + } + } + }, + options: { + spriteText: true + } +}; diff --git a/test/fixtures/box/initAnimation.png b/test/fixtures/box/initAnimation.png new file mode 100644 index 000000000..29a78587d Binary files /dev/null and b/test/fixtures/box/initAnimation.png differ diff --git a/test/fixtures/ellipse/initAnimation.js b/test/fixtures/ellipse/initAnimation.js new file mode 100644 index 000000000..d7f0b9a0a --- /dev/null +++ b/test/fixtures/ellipse/initAnimation.js @@ -0,0 +1,85 @@ +module.exports = { + tolerance: 0.0075, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'] + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + common: { + init: () => true, + }, + annotations: { + ellipse1: { + type: 'ellipse', + init: true, + xMin: 0.5, + xMax: 2.5, + yMin: 8, + yMax: 13, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgb(255, 99, 132)', + borderWidth: 1 + }, + ellipse2: { + type: 'ellipse', + xMin: 'May', + xMax: 'July', + yMin: 11, + yMax: 15, + init: () => undefined, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1 + }, + ellipse3: { + type: 'ellipse', + xMin: 0.5, + xMax: 'May', + yMin: 16, + yMax: 20, + init: () => true, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1 + }, + ellipse4: { + type: 'ellipse', + xMin: 0.5, + xMax: 'May', + yMin: 0.5, + yMax: 4, + init: () => ({y: 0, y2: 0}), + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1 + }, + ellipse5: { + type: 'ellipse', + xMin: 'April', + xMax: 'July', + yMin: 5, + yMax: 9, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1 + } + } + } + } + } + }, + options: { + spriteText: true + } +}; diff --git a/test/fixtures/ellipse/initAnimation.png b/test/fixtures/ellipse/initAnimation.png new file mode 100644 index 000000000..b3df65d29 Binary files /dev/null and b/test/fixtures/ellipse/initAnimation.png differ diff --git a/test/fixtures/label/initAnimation.js b/test/fixtures/label/initAnimation.js new file mode 100644 index 000000000..c4e2d9a02 --- /dev/null +++ b/test/fixtures/label/initAnimation.js @@ -0,0 +1,90 @@ +module.exports = { + tolerance: 0.0075, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'] + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + common: { + init: () => true, + }, + annotations: { + label1: { + type: 'label', + init: true, + xMin: 0.5, + xMax: 2.5, + yMin: 8, + yMax: 13, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgb(255, 99, 132)', + borderWidth: 1, + content: 'true' + }, + label2: { + type: 'label', + xMin: 'May', + xMax: 'July', + yMin: 11, + yMax: 15, + init: () => undefined, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + content: 'callback undef' + }, + label3: { + type: 'label', + xMin: 0.5, + xMax: 'May', + yMin: 16, + yMax: 20, + init: () => true, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + content: 'callback' + }, + label4: { + type: 'label', + xMin: 0.5, + xMax: 'May', + yMin: 0.5, + yMax: 4, + init: () => ({y: 0, y2: 0}), + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + content: 'callback object' + }, + label5: { + type: 'label', + xMin: 'April', + xMax: 'July', + yMin: 5, + yMax: 9, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + content: 'fallback' + } + } + } + } + } + }, + options: { + spriteText: true + } +}; diff --git a/test/fixtures/label/initAnimation.png b/test/fixtures/label/initAnimation.png new file mode 100644 index 000000000..13b88efda Binary files /dev/null and b/test/fixtures/label/initAnimation.png differ diff --git a/test/fixtures/line/initAnimation.js b/test/fixtures/line/initAnimation.js new file mode 100644 index 000000000..8d33e43ad --- /dev/null +++ b/test/fixtures/line/initAnimation.js @@ -0,0 +1,95 @@ +module.exports = { + tolerance: 0.0075, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'] + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + common: { + init: () => true, + }, + annotations: { + line1: { + type: 'line', + init: true, + xMin: 0.5, + xMax: 2.5, + yMin: 8, + yMax: 13, + borderColor: 'rgb(255, 99, 132)', + label: { + display: true, + content: 'true' + } + }, + line2: { + type: 'line', + xMin: 'May', + xMax: 'July', + yMin: 11, + yMax: 15, + init: () => undefined, + borderColor: 'rgba(255, 99, 132)', + label: { + display: true, + content: 'callback undef' + } + }, + line3: { + type: 'line', + xMin: 0.5, + xMax: 'May', + yMin: 16, + yMax: 20, + init: () => true, + borderColor: 'rgba(255, 99, 132)', + label: { + display: true, + content: 'callback' + } + }, + line4: { + type: 'line', + xMin: 0.5, + xMax: 'May', + yMin: 0.5, + yMax: 4, + init: () => ({y: 0, y2: 0}), + borderColor: 'rgba(255, 99, 132)', + label: { + display: true, + content: 'callback object' + } + }, + line5: { + type: 'line', + xMin: 'April', + xMax: 'July', + yMin: 5, + yMax: 9, + borderColor: 'rgba(255, 99, 132)', + label: { + display: true, + content: 'fallback' + } + } + } + } + } + } + }, + options: { + spriteText: true + } +}; diff --git a/test/fixtures/line/initAnimation.png b/test/fixtures/line/initAnimation.png new file mode 100644 index 000000000..5b2d10512 Binary files /dev/null and b/test/fixtures/line/initAnimation.png differ diff --git a/test/fixtures/point/initAnimation.js b/test/fixtures/point/initAnimation.js new file mode 100644 index 000000000..653b23302 --- /dev/null +++ b/test/fixtures/point/initAnimation.js @@ -0,0 +1,74 @@ +module.exports = { + tolerance: 0.0075, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'] + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + common: { + init: () => true, + }, + annotations: { + point1: { + type: 'point', + init: true, + xMin: 0.5, + xMax: 2.5, + yMin: 8, + yMax: 13, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgb(255, 99, 132)', + borderWidth: 1 + }, + point2: { + type: 'point', + xMin: 'May', + xMax: 'July', + yMin: 11, + yMax: 15, + init: () => undefined, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1 + }, + point3: { + type: 'point', + xMin: 0.5, + xMax: 'May', + yMin: 16, + yMax: 20, + init: () => true, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1 + }, + point4: { + type: 'point', + xMin: 'April', + xMax: 'July', + yMin: 5, + yMax: 9, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1 + } + } + } + } + } + }, + options: { + spriteText: true + } +}; diff --git a/test/fixtures/point/initAnimation.png b/test/fixtures/point/initAnimation.png new file mode 100644 index 000000000..ce0c742f1 Binary files /dev/null and b/test/fixtures/point/initAnimation.png differ diff --git a/test/fixtures/polygon/initAnimation.js b/test/fixtures/polygon/initAnimation.js new file mode 100644 index 000000000..25262f7a1 --- /dev/null +++ b/test/fixtures/polygon/initAnimation.js @@ -0,0 +1,82 @@ +module.exports = { + tolerance: 0.0075, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'] + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + common: { + init: () => true, + }, + annotations: { + polygon1: { + type: 'polygon', + init: true, + xMin: 0.5, + xMax: 2.5, + yMin: 8, + yMax: 13, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgb(255, 99, 132)', + borderWidth: 1, + sides: 5, + radius: 40 + }, + polygon2: { + type: 'polygon', + xMin: 'May', + xMax: 'July', + yMin: 11, + yMax: 15, + init: () => undefined, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + sides: 6, + radius: 40 + }, + polygon3: { + type: 'polygon', + xMin: 0.5, + xMax: 'May', + yMin: 16, + yMax: 20, + init: () => true, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + sides: 7, + radius: 40 + }, + polygon4: { + type: 'polygon', + xMin: 'April', + xMax: 'July', + yMin: 5, + yMax: 9, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + borderColor: 'rgba(255, 99, 132)', + borderWidth: 1, + sides: 4, + radius: 40 + } + } + } + } + } + }, + options: { + spriteText: true + } +}; diff --git a/test/fixtures/polygon/initAnimation.png b/test/fixtures/polygon/initAnimation.png new file mode 100644 index 000000000..5ac3f2675 Binary files /dev/null and b/test/fixtures/polygon/initAnimation.png differ diff --git a/test/specs/animation.spec.js b/test/specs/animation.spec.js new file mode 100644 index 000000000..7d2bb4e0c --- /dev/null +++ b/test/specs/animation.spec.js @@ -0,0 +1,240 @@ +describe('Initial animation', function() { + + const types = { + box: 'x', + ellipse: 'width', + label: 'x', + line: 'x', + point: 'radius', + polygon: 'y' + }; + + for (const type of Object.keys(types)) { + + it(`should reach the final position once in ${type} annotation`, function(done) { + + const chartConfig = { + type: 'scatter', + options: { + animation: { + duration: 500 + }, + scales: { + x: { + display: false, + min: 0, + max: 10 + }, + y: { + display: false, + min: 0, + max: 10 + } + }, + plugins: { + legend: false, + annotation: { + common: { + } + } + } + } + }; + + const options = { + type, + xMin: 4, + yMin: 4, + xMax: 6, + yMax: 6, + radius: 40, + borderWidth: 0 + }; + + const pluginOpts = chartConfig.options.plugins.annotation; + const commonOpts = pluginOpts.common; + const property = types[type]; + let cycles = 0; + + chartConfig.plugins = [{ + id: 'initAnimEnabled', + afterInit(chart) { + chart.annotationCount = 0; + }, + afterDraw(chart) { + let element = window.getAnnotationElements(chart)[0]; + chart.init = element.options.init; + if (type === 'polygon') { + element = element.elements[0]; + } + const opts = element.getProps([property], true); + const valueFinal = opts[property]; + const value = element[property]; + if (value === valueFinal) { + chart.annotationCount++; + } + }, + afterRender(chart) { + cycles++; + if (cycles === 2) { + expect(chart.annotationCount <= 1 && chart.init).withContext(`with count ${chart.annotationCount}, init ${chart.init}`).toEqual(true); + done(); + } + } + }]; + + [commonOpts, options].forEach(function(targetOptions) { + delete commonOpts.init; + delete options.init; + targetOptions.init = true; + pluginOpts.annotations = [options]; + window.acquireChart(chartConfig); + }); + + }); + + it(`should not update the position in ${type} annotation`, function(done) { + + const chartConfig = { + type: 'scatter', + options: { + animation: { + duration: 500 + }, + scales: { + x: { + display: false, + min: 0, + max: 10 + }, + y: { + display: false, + min: 0, + max: 10 + } + }, + plugins: { + legend: false, + annotation: { + common: { + } + } + } + } + }; + + const options = { + type, + xMin: 4, + yMin: 4, + xMax: 6, + yMax: 6, + radius: 40, + borderWidth: 0 + }; + + const pluginOpts = chartConfig.options.plugins.annotation; + const commonOpts = pluginOpts.common; + const property = types[type]; + let cycles = 0; + + chartConfig.plugins = [{ + id: 'initAnimDisabled', + afterInit(chart) { + chart.annotationCount = 0; + }, + afterDraw(chart) { + let element = window.getAnnotationElements(chart)[0]; + chart.init = element.options.init; + if (type === 'polygon') { + element = element.elements[0]; + } + const opts = element.getProps([property], true); + const valueFinal = opts[property]; + const value = element[property]; + if (value !== valueFinal) { + chart.annotationCount++; + } + }, + afterRender(chart) { + expect(chart.annotationCount === 0 && !chart.init).withContext(`with count ${chart.annotationCount}, init ${chart.init}`).toEqual(true); + cycles++; + if (cycles === 2) { + done(); + } + } + }]; + + [commonOpts, options].forEach(function(targetOptions) { + delete commonOpts.init; + delete options.init; + targetOptions.init = false; + pluginOpts.annotations = [options]; + window.acquireChart(chartConfig); + }); + + }); + + it(` callback should not receive the element properties in ${type} annotation`, function(done) { + + const chartConfig = { + type: 'scatter', + options: { + animation: { + duration: 500 + }, + scales: { + x: { + display: false, + min: 0, + max: 10 + }, + y: { + display: false, + min: 0, + max: 10 + } + }, + plugins: { + legend: false, + annotation: { + common: { + } + } + } + } + }; + + const options = { + type, + xMin: 4, + yMin: 4, + xMax: 6, + yMax: 6, + radius: 40, + borderWidth: 0 + }; + + const pluginOpts = chartConfig.options.plugins.annotation; + const commonOpts = pluginOpts.common; + let cycles = 0; + + [commonOpts, options].forEach(function(targetOptions) { + delete commonOpts.init; + delete options.init; + targetOptions.init = function({properties}) { + expect(typeof properties === 'object').toEqual(true); + cycles++; + if (cycles === 2) { + done(); + } + }; + pluginOpts.annotations = [options]; + window.acquireChart(chartConfig); + }); + + }); + + } + +}); diff --git a/types/element.d.ts b/types/element.d.ts index 557aa4aa4..8ed43be07 100644 --- a/types/element.d.ts +++ b/types/element.d.ts @@ -8,7 +8,8 @@ export interface AnnotationBoxModel { centerX: number, centerY: number, height: number, - width: number + width: number, + radius?: number } export interface AnnotationElement extends AnnotationBoxModel { diff --git a/types/options.d.ts b/types/options.d.ts index 5969e72aa..fb1c9cf1c 100644 --- a/types/options.d.ts +++ b/types/options.d.ts @@ -1,6 +1,7 @@ -import { Color, PointStyle, BorderRadius, CoreInteractionOptions } from 'chart.js'; +import { Chart, Color, PointStyle, BorderRadius, CoreInteractionOptions } from 'chart.js'; import { AnnotationEvents, PartialEventContext, EventContext } from './events'; import { LabelOptions, BoxLabelOptions, LabelTypeOptions } from './label'; +import { AnnotationBoxModel } from './element'; export type DrawTime = 'afterDraw' | 'afterDatasetsDraw' | 'beforeDraw' | 'beforeDatasetsDraw'; @@ -41,6 +42,7 @@ export interface CoreAnnotationOptions extends AnnotationEvents, ShadowOptions, borderWidth?: Scriptable, display?: Scriptable, drawTime?: Scriptable, + init: boolean | ((chart: Chart, properties: AnnotationBoxModel, options: AnnotationOptions) => void | boolean | AnnotationBoxModel), id?: string, xMax?: Scriptable, xMin?: Scriptable,