diff --git a/src/annotation.js b/src/annotation.js index 9d8fec04d..ab895a939 100644 --- a/src/annotation.js +++ b/src/annotation.js @@ -1,6 +1,7 @@ import {Chart} from 'chart.js'; import {clipArea, unclipArea, isObject, isArray} from 'chart.js/helpers'; import {handleEvent, eventHooks, updateListeners} from './events'; +import {handleActiveElements} from './hover'; import {invokeHook, elementHooks, updateHooks} from './hooks'; import {adjustScaleRange, verifyScaleOptions} from './scale'; import {updateElements, resolveType} from './elements'; @@ -38,7 +39,8 @@ export default { moveListened: false, hooks: {}, hooked: false, - hovered: [] + hovered: [], + activeElements: [] }); }, @@ -92,7 +94,8 @@ export default { beforeEvent(chart, args, options) { const state = chartStates.get(chart); - if (handleEvent(state, args.event, options)) { + const hovered = handleActiveElements(chart, state, args.event, options); + if (handleEvent(state, args.event, options) || hovered) { args.changed = true; } }, @@ -118,6 +121,12 @@ export default { axis: undefined, intersect: undefined }, + hover: { + enabled: true, + mode: undefined, + axis: undefined, + intersect: undefined + }, common: { drawTime: 'afterDatasetsDraw', label: { @@ -135,6 +144,9 @@ export default { interaction: { _fallback: true }, + hover: { + _fallback: 'interaction' + }, common: { label: { _fallback: true diff --git a/src/elements.js b/src/elements.js index ba996cc53..284ef6dd0 100644 --- a/src/elements.js +++ b/src/elements.js @@ -9,11 +9,13 @@ const directUpdater = { }; const hooks = eventHooks.concat(elementHooks); +const getPrefixes = (el) => el.active ? ['hover', ''] : ['']; /** * @typedef { import("chart.js").Chart } Chart * @typedef { import("chart.js").UpdateMode } UpdateMode * @typedef { import('../../types/options').AnnotationPluginOptions } AnnotationPluginOptions + * @typedef { import('../../types/element').AnnotationElement } AnnotationElement */ /** @@ -29,6 +31,21 @@ export function resolveType(type = 'line') { return 'line'; } +/** + * @param {Chart} chart + * @param {Object} state + * @param {AnnotationPluginOptions} options + * @param {AnnotationElement[]} elements + */ +export function updateActiveElements(chart, state, options, elements) { + const animations = resolveAnimations(chart, options.animations, 'active'); + + elements.forEach(function(el) { + const index = state.elements.indexOf(el); + updateElement(chart, el, state.annotations[index], animations); + }); +} + /** * @param {Chart} chart * @param {Object} state @@ -44,30 +61,34 @@ export function updateElements(chart, state, options, mode) { for (let i = 0; i < annotations.length; i++) { const annotationOptions = annotations[i]; const element = getOrCreateElement(elements, i, annotationOptions.type); - const resolver = annotationOptions.setContext(getContext(chart, element, annotationOptions)); - const properties = element.resolveElementProperties(chart, resolver); - - properties.skip = toSkip(properties); + updateElement(chart, element, annotationOptions, animations); + } +} - if ('elements' in properties) { - updateSubElements(element, properties, resolver, animations); - // Remove the sub-element definitions from properties, so the actual elements - // are not overwritten by their definitions - delete properties.elements; - } +function updateElement(chart, element, options, animations) { + const resolver = options.setContext(getContext(chart, element, options), getPrefixes(element)); + const properties = element.resolveElementProperties(chart, resolver); - if (!defined(element.x)) { - // If the element is newly created, assing the properties directly - to - // make them readily awailable to any scriptable options. If we do not do this, - // the properties retruned by `resolveElementProperties` are available only - // after options resolution. - Object.assign(element, properties); - } + properties.skip = toSkip(properties); - properties.options = resolveAnnotationOptions(resolver); + if ('elements' in properties) { + updateSubElements(element, properties, resolver, animations); + // Remove the sub-element definitions from properties, so the actual elements + // are not overwritten by their definitions + delete properties.elements; + } - animations.update(element, properties); + if (!defined(element.x)) { + // If the element is newly created, assing the properties directly - to + // make them readily awailable to any scriptable options. If we do not do this, + // the properties retruned by `resolveElementProperties` are available only + // after options resolution. + Object.assign(element, properties); } + + properties.options = resolveAnnotationOptions(resolver); + + animations.update(element, properties); } function toSkip(properties) { @@ -88,7 +109,7 @@ function updateSubElements(mainElement, {elements, initProperties}, resolver, an const definition = elements[i]; const properties = definition.properties; const subElement = getOrCreateElement(subElements, i, definition.type, initProperties); - const subResolver = resolver[definition.optionScope].override(definition); + const subResolver = resolver[definition.optionScope].override(definition, getPrefixes(mainElement)); properties.options = resolveAnnotationOptions(subResolver); animations.update(subElement, properties); } @@ -121,12 +142,12 @@ function resolveAnnotationOptions(resolver) { return result; } -function resolveObj(resolver, defs) { +function resolveObj(resolver, defs, active) { const result = {}; for (const prop of Object.keys(defs)) { const optDefs = defs[prop]; const value = resolver[prop]; - result[prop] = isObject(optDefs) ? resolveObj(value, optDefs) : value; + result[prop] = isObject(optDefs) ? resolveObj(value, optDefs, active) : value; } return result; } diff --git a/src/hover.js b/src/hover.js new file mode 100644 index 000000000..264e49bb2 --- /dev/null +++ b/src/hover.js @@ -0,0 +1,62 @@ +import {getElements} from './interaction'; +import {updateActiveElements} from './elements'; + +/** + * @typedef { import("chart.js").Chart } Chart + * @typedef { import('../../types/options').AnnotationPluginOptions } AnnotationPluginOptions + */ + +/** + * @param {Chart} chart + * @param {Object} state + * @param {ChartEvent} event + * @param {AnnotationPluginOptions} options + * @return {boolean|undefined} + */ +export function handleActiveElements(chart, state, event, options) { + if (options.hover.enabled) { + switch (event.type) { + case 'mousemove': + case 'mouseout': + return handleMoveEvents(chart, state, event, options); + default: + } + } +} + +function handleMoveEvents(chart, state, event, options) { + let elements; + + if (event.type === 'mousemove') { + elements = getElements(state, event, options.hover); + } else { + elements = []; + } + + if (empty(state.activeElements, elements)) { + return false; + } + + const unhovered = state.activeElements.filter((el) => !elements.includes(el)); + setActive(unhovered, false); + state.activeElements = elements; + const newHovered = elements.filter((el) => !el.active); + if (empty(unhovered, newHovered)) { + return false; + } + setActive(newHovered, true); + updateActiveElements(chart, state, options, unhovered.concat(newHovered)); + return true; +} + +function empty(arr1, arr2) { + return !arr1.length && !arr2.length; +} + +function setActive(elements, active) { + const result = active ? elements.filter((el) => !el.active) : elements; + result.forEach(function(el) { + el.active = active; + }); + return result; +} diff --git a/test/utils.js b/test/utils.js index 3baa7e7fc..a43bc5b70 100644 --- a/test/utils.js +++ b/test/utils.js @@ -56,6 +56,9 @@ export function scatterChart(xMax, yMax, annotations) { plugins: { legend: false, annotation: { + hover: { + enabled: false + }, annotations } }