Issue 4991 (#7084)

* Fix remaining handleEvent issues

* Reduce lines

* Update tooltip always on replay

* Address issues

* Fix test

* More tooltip fixing

* Extend comment
This commit is contained in:
Jukka Kurkela 2020-02-25 21:35:32 +02:00 committed by GitHub
parent a9ae64f1e2
commit 5e489f16f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 219 additions and 145 deletions

View File

@ -212,7 +212,7 @@ export default class Chart {
this.chartArea = undefined;
this.data = undefined;
this.active = undefined;
this.lastActive = undefined;
this.lastActive = [];
this._lastEvent = undefined;
/** @type {{resize?: function}} */
this._listeners = {};
@ -581,7 +581,7 @@ export default class Chart {
// Replay last event from before update
if (me._lastEvent) {
me._eventHandler(me._lastEvent);
me._eventHandler(me._lastEvent, true);
}
me.render();
@ -808,10 +808,10 @@ export default class Chart {
return Interaction.modes.index(this, e, {intersect: false});
}
getElementsAtEventForMode(e, mode, options) {
getElementsAtEventForMode(e, mode, options, useFinalPosition) {
const method = Interaction.modes[mode];
if (typeof method === 'function') {
return method(this, e, options);
return method(this, e, options, useFinalPosition);
}
return [];
@ -1021,16 +1021,16 @@ export default class Chart {
/**
* @private
*/
_eventHandler(e) {
_eventHandler(e, replay) {
const me = this;
if (plugins.notify(me, 'beforeEvent', [e]) === false) {
if (plugins.notify(me, 'beforeEvent', [e, replay]) === false) {
return;
}
me._handleEvent(e);
me._handleEvent(e, replay);
plugins.notify(me, 'afterEvent', [e]);
plugins.notify(me, 'afterEvent', [e, replay]);
me.render();
@ -1040,23 +1040,38 @@ export default class Chart {
/**
* Handle an event
* @param {IEvent} e the event to handle
* @param {boolean} [replay] - true if the event was replayed by `update`
* @return {boolean} true if the chart needs to re-render
* @private
*/
_handleEvent(e) {
_handleEvent(e, replay) {
const me = this;
const options = me.options || {};
const options = me.options;
const hoverOptions = options.hover;
let changed = false;
me.lastActive = me.lastActive || [];
// If the event is replayed from `update`, we should evaluate with the final positions.
//
// The `replay`:
// It's the last event (excluding click) that has occured before `update`.
// So mouse has not moved. It's also over the chart, because there is a `replay`.
//
// The why:
// If animations are active, the elements haven't moved yet compared to state before update.
// But if they will, we are activating the elements that would be active, if this check
// was done after the animations have completed. => "final positions".
// If there is no animations, the "final" and "current" positions are equal.
// This is done so we do not have to evaluate the active elements each animation frame
// - it would be expensive.
const useFinalPosition = replay;
let changed = false;
// Find Active Elements for hover and tooltips
if (e.type === 'mouseout') {
me.active = [];
me._lastEvent = null;
} else {
me.active = me.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions);
me.active = me.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions, useFinalPosition);
me._lastEvent = e.type === 'click' ? me._lastEvent : e;
}
@ -1072,7 +1087,7 @@ export default class Chart {
}
changed = !helpers._elementsEqual(me.active, me.lastActive);
if (changed) {
if (changed || replay) {
me._updateHoverStyles();
}

View File

@ -5,27 +5,48 @@ export default class Element {
static extend = inherits;
/**
* @param {object} [cfg] optional configuration
*/
constructor(cfg) {
this.x = undefined;
this.y = undefined;
this.hidden = undefined;
this.hidden = false;
this.active = false;
this.options = undefined;
this.$animations = undefined;
if (cfg) {
Object.assign(this, cfg);
}
}
tooltipPosition() {
return {
x: this.x,
y: this.y
};
/**
* @param {boolean} [useFinalPosition]
*/
tooltipPosition(useFinalPosition) {
const {x, y} = this.getProps(['x', 'y'], useFinalPosition);
return {x, y};
}
hasValue() {
return isNumber(this.x) && isNumber(this.y);
}
/**
* Gets the current or final value of each prop. Can return extra properties (whole object).
* @param {string[]} props - properties to get
* @param {boolean} [final] - get the final value (animation target)
* @return {object}
*/
getProps(props, final) {
const me = this;
const anims = this.$animations;
if (!final || !anims) {
// let's not create an object, if not needed
return me;
}
const ret = {};
props.forEach(prop => {
ret[prop] = anims[prop] && anims[prop].active ? anims[prop]._to : me[prop];
});
return ret;
}
}

View File

@ -5,7 +5,8 @@ import {_lookupByKey, _rlookupByKey} from '../helpers/helpers.collection';
/**
* @typedef { import("./core.controller").default } Chart
* @typedef { import("../platform/platform.base").IEvent } IEvent
* @typedef {{axis?:'x'|'y'|'xy', intersect:boolean}} IInteractionOptions
* @typedef {{axis?: string, intersect?: boolean}} InteractionOptions
* @typedef {{datasetIndex: number, index: number, element: import("../core/core.element").default}} InteractionItem
*/
/**
@ -121,9 +122,10 @@ function getDistanceMetricForAxis(axis) {
* @param {Chart} chart - the chart
* @param {object} position - the point to be nearest to
* @param {string} axis - the axis mode. x|y|xy
* @return {object[]} the nearest items
* @param {boolean} [useFinalPosition] - use the element's animation target instead of current position
* @return {InteractionItem[]} the nearest items
*/
function getIntersectItems(chart, position, axis) {
function getIntersectItems(chart, position, axis, useFinalPosition) {
const items = [];
if (!_isPointInArea(position, chart.chartArea)) {
@ -131,7 +133,7 @@ function getIntersectItems(chart, position, axis) {
}
const evaluationFunc = function(element, datasetIndex, index) {
if (element.inRange(position.x, position.y)) {
if (element.inRange(position.x, position.y, useFinalPosition)) {
items.push({element, datasetIndex, index});
}
};
@ -146,9 +148,10 @@ function getIntersectItems(chart, position, axis) {
* @param {object} position - the point to be nearest to
* @param {string} axis - the axes along which to measure distance
* @param {boolean} [intersect] - if true, only consider items that intersect the position
* @return {object[]} the nearest items
* @param {boolean} [useFinalPosition] - use the elements animation target instead of current position
* @return {InteractionItem[]} the nearest items
*/
function getNearestItems(chart, position, axis, intersect) {
function getNearestItems(chart, position, axis, intersect, useFinalPosition) {
const distanceMetric = getDistanceMetricForAxis(axis);
let minDistance = Number.POSITIVE_INFINITY;
let items = [];
@ -158,11 +161,11 @@ function getNearestItems(chart, position, axis, intersect) {
}
const evaluationFunc = function(element, datasetIndex, index) {
if (intersect && !element.inRange(position.x, position.y)) {
if (intersect && !element.inRange(position.x, position.y, useFinalPosition)) {
return;
}
const center = element.getCenterPoint();
const center = element.getCenterPoint(useFinalPosition);
const distance = distanceMetric(position, center);
if (distance < minDistance) {
items = [{element, datasetIndex, index}];
@ -191,14 +194,17 @@ export default {
* @since v2.4.0
* @param {Chart} chart - the chart we are returning items from
* @param {Event} e - the event we are find things at
* @param {IInteractionOptions} options - options to use during interaction
* @return {Object[]} Array of elements that are under the point. If none are found, an empty array is returned
* @param {InteractionOptions} options - options to use
* @param {boolean} [useFinalPosition] - use final element position (animation target)
* @return {InteractionItem[]} - items that are found
*/
index(chart, e, options) {
index(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);
// Default axis for index mode is 'x' to match old behaviour
const axis = options.axis || 'x';
const items = options.intersect ? getIntersectItems(chart, position, axis) : getNearestItems(chart, position, axis);
const items = options.intersect
? getIntersectItems(chart, position, axis, useFinalPosition)
: getNearestItems(chart, position, axis, false, useFinalPosition);
const elements = [];
if (!items.length) {
@ -224,13 +230,16 @@ export default {
* @function Chart.Interaction.modes.dataset
* @param {Chart} chart - the chart we are returning items from
* @param {Event} e - the event we are find things at
* @param {IInteractionOptions} options - options to use during interaction
* @return {Object[]} Array of elements that are under the point. If none are found, an empty array is returned
* @param {InteractionOptions} options - options to use
* @param {boolean} [useFinalPosition] - use final element position (animation target)
* @return {InteractionItem[]} - items that are found
*/
dataset(chart, e, options) {
dataset(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);
const axis = options.axis || 'xy';
let items = options.intersect ? getIntersectItems(chart, position, axis) : getNearestItems(chart, position, axis);
let items = options.intersect
? getIntersectItems(chart, position, axis, useFinalPosition) :
getNearestItems(chart, position, axis, false, useFinalPosition);
if (items.length > 0) {
const datasetIndex = items[0].datasetIndex;
@ -250,13 +259,14 @@ export default {
* @function Chart.Interaction.modes.intersect
* @param {Chart} chart - the chart we are returning items from
* @param {Event} e - the event we are find things at
* @param {IInteractionOptions} options - options to use
* @return {Object[]} Array of elements that are under the point. If none are found, an empty array is returned
* @param {InteractionOptions} options - options to use
* @param {boolean} [useFinalPosition] - use final element position (animation target)
* @return {InteractionItem[]} - items that are found
*/
point(chart, e, options) {
point(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);
const axis = options.axis || 'xy';
return getIntersectItems(chart, position, axis);
return getIntersectItems(chart, position, axis, useFinalPosition);
},
/**
@ -264,13 +274,14 @@ export default {
* @function Chart.Interaction.modes.intersect
* @param {Chart} chart - the chart we are returning items from
* @param {Event} e - the event we are find things at
* @param {IInteractionOptions} options - options to use
* @return {Object[]} Array of elements that are under the point. If none are found, an empty array is returned
* @param {InteractionOptions} options - options to use
* @param {boolean} [useFinalPosition] - use final element position (animation target)
* @return {InteractionItem[]} - items that are found
*/
nearest(chart, e, options) {
nearest(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);
const axis = options.axis || 'xy';
return getNearestItems(chart, position, axis, options.intersect);
return getNearestItems(chart, position, axis, options.intersect, useFinalPosition);
},
/**
@ -278,20 +289,21 @@ export default {
* @function Chart.Interaction.modes.x
* @param {Chart} chart - the chart we are returning items from
* @param {Event} e - the event we are find things at
* @param {IInteractionOptions} options - options to use
* @return {Object[]} Array of elements that are under the point. If none are found, an empty array is returned
* @param {InteractionOptions} options - options to use
* @param {boolean} [useFinalPosition] - use final element position (animation target)
* @return {InteractionItem[]} - items that are found
*/
x(chart, e, options) {
x(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);
const items = [];
let intersectsItem = false;
evaluateAllVisibleItems(chart, (element, datasetIndex, index) => {
if (element.inXRange(position.x)) {
if (element.inXRange(position.x, useFinalPosition)) {
items.push({element, datasetIndex, index});
}
if (element.inRange(position.x, position.y)) {
if (element.inRange(position.x, position.y, useFinalPosition)) {
intersectsItem = true;
}
});
@ -309,20 +321,21 @@ export default {
* @function Chart.Interaction.modes.y
* @param {Chart} chart - the chart we are returning items from
* @param {Event} e - the event we are find things at
* @param {IInteractionOptions} options - options to use
* @return {Object[]} Array of elements that are under the point. If none are found, an empty array is returned
* @param {InteractionOptions} options - options to use
* @param {boolean} [useFinalPosition] - use final element position (animation target)
* @return {InteractionItem[]} - items that are found
*/
y(chart, e, options) {
y(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);
const items = [];
let intersectsItem = false;
evaluateAllVisibleItems(chart, (element, datasetIndex, index) => {
if (element.inYRange(position.y)) {
if (element.inYRange(position.y, useFinalPosition)) {
items.push({element, datasetIndex, index});
}
if (element.inRange(position.x, position.y)) {
if (element.inRange(position.x, position.y, useFinalPosition)) {
intersectsItem = true;
}
});

View File

@ -365,6 +365,7 @@ export default new PluginService();
* @param {Chart} chart - The chart instance.
* @param {IEvent} event - The event object.
* @param {object} options - The plugin options.
* @param {boolean} replay - True if this event is replayed from `Chart.update`
*/
/**
* @method IPlugin#afterEvent
@ -373,6 +374,7 @@ export default new PluginService();
* @param {Chart} chart - The chart instance.
* @param {IEvent} event - The event object.
* @param {object} options - The plugin options.
* @param {boolean} replay - True if this event is replayed from `Chart.update`
*/
/**
* @method IPlugin#resize

View File

@ -106,38 +106,49 @@ export default class Arc extends Element {
/**
* @param {number} chartX
* @param {number} chartY
* @param {boolean} [useFinalPosition]
*/
inRange(chartX, chartY) {
const me = this;
const {angle, distance} = getAngleFromPoint(me, {x: chartX, y: chartY});
// Check if within the range of the open/close angle
const betweenAngles = _angleBetween(angle, me.startAngle, me.endAngle);
const withinRadius = (distance >= me.innerRadius && distance <= me.outerRadius);
inRange(chartX, chartY, useFinalPosition) {
const point = this.getProps(['x', 'y'], useFinalPosition);
const {angle, distance} = getAngleFromPoint(point, {x: chartX, y: chartY});
const {startAngle, endAngle, innerRadius, outerRadius, circumference} = this.getProps([
'startAngle',
'endAngle',
'innerRadius',
'outerRadius',
'circumference'
], useFinalPosition);
const betweenAngles = circumference >= TAU || _angleBetween(angle, startAngle, endAngle);
const withinRadius = (distance >= innerRadius && distance <= outerRadius);
return (betweenAngles && withinRadius);
}
getCenterPoint() {
const me = this;
const halfAngle = (me.startAngle + me.endAngle) / 2;
const halfRadius = (me.innerRadius + me.outerRadius) / 2;
/**
* @param {boolean} [useFinalPosition]
*/
getCenterPoint(useFinalPosition) {
const {x, y, startAngle, endAngle, innerRadius, outerRadius} = this.getProps([
'x',
'y',
'startAngle',
'endAngle',
'innerRadius',
'outerRadius'
], useFinalPosition);
const halfAngle = (startAngle + endAngle) / 2;
const halfRadius = (innerRadius + outerRadius) / 2;
return {
x: me.x + Math.cos(halfAngle) * halfRadius,
y: me.y + Math.sin(halfAngle) * halfRadius
x: x + Math.cos(halfAngle) * halfRadius,
y: y + Math.sin(halfAngle) * halfRadius
};
}
tooltipPosition() {
const me = this;
const centreAngle = me.startAngle + ((me.endAngle - me.startAngle) / 2);
const rangeFromCentre = (me.outerRadius - me.innerRadius) / 2 + me.innerRadius;
return {
x: me.x + (Math.cos(centreAngle) * rangeFromCentre),
y: me.y + (Math.sin(centreAngle) * rangeFromCentre)
};
/**
* @param {boolean} [useFinalPosition]
*/
tooltipPosition(useFinalPosition) {
return this.getCenterPoint(useFinalPosition);
}
draw(ctx) {

View File

@ -34,23 +34,28 @@ export default class Point extends Element {
}
}
inRange(mouseX, mouseY) {
inRange(mouseX, mouseY, useFinalPosition) {
const options = this.options;
return ((Math.pow(mouseX - this.x, 2) + Math.pow(mouseY - this.y, 2)) < Math.pow(options.hitRadius + options.radius, 2));
const {x, y} = this.getProps(['x', 'y'], useFinalPosition);
return ((Math.pow(mouseX - x, 2) + Math.pow(mouseY - y, 2)) < Math.pow(options.hitRadius + options.radius, 2));
}
inXRange(mouseX) {
inXRange(mouseX, useFinalPosition) {
const options = this.options;
return (Math.abs(mouseX - this.x) < options.radius + options.hitRadius);
const {x} = this.getProps(['x'], useFinalPosition);
return (Math.abs(mouseX - x) < options.radius + options.hitRadius);
}
inYRange(mouseY) {
inYRange(mouseY, useFinalPosition) {
const options = this.options;
return (Math.abs(mouseY - this.y) < options.radius + options.hitRadius);
const {y} = this.getProps(['x'], useFinalPosition);
return (Math.abs(mouseY - y) < options.radius + options.hitRadius);
}
getCenterPoint() {
return {x: this.x, y: this.y};
getCenterPoint(useFinalPosition) {
const {x, y} = this.getProps(['x', 'y'], useFinalPosition);
return {x, y};
}
size() {
@ -60,15 +65,6 @@ export default class Point extends Element {
return (radius + borderWidth) * 2;
}
tooltipPosition() {
const options = this.options;
return {
x: this.x,
y: this.y,
padding: options.radius + options.borderWidth
};
}
draw(ctx, chartArea) {
const me = this;
const options = me.options;

View File

@ -15,33 +15,31 @@ defaults.set('elements', {
/**
* Helper function to get the bounds of the bar regardless of the orientation
* @param bar {Rectangle} the bar
* @param {Rectangle} bar the bar
* @param {boolean} [useFinalPosition]
* @return {object} bounds of the bar
* @private
*/
function getBarBounds(bar) {
let x1, x2, y1, y2, half;
function getBarBounds(bar, useFinalPosition) {
const {x, y, base, width, height} = bar.getProps(['x', 'y', 'base', 'width', 'height'], useFinalPosition);
let left, right, top, bottom, half;
if (bar.horizontal) {
half = bar.height / 2;
x1 = Math.min(bar.x, bar.base);
x2 = Math.max(bar.x, bar.base);
y1 = bar.y - half;
y2 = bar.y + half;
half = height / 2;
left = Math.min(x, base);
right = Math.max(x, base);
top = y - half;
bottom = y + half;
} else {
half = bar.width / 2;
x1 = bar.x - half;
x2 = bar.x + half;
y1 = Math.min(bar.y, bar.base);
y2 = Math.max(bar.y, bar.base);
half = width / 2;
left = x - half;
right = x + half;
top = Math.min(y, base);
bottom = Math.max(y, base);
}
return {
left: x1,
top: y1,
right: x2,
bottom: y2
};
return {left, top, right, bottom};
}
function swap(orig, v1, v2) {
@ -116,10 +114,10 @@ function boundingRects(bar) {
};
}
function inRange(bar, x, y) {
function inRange(bar, x, y, useFinalPosition) {
const skipX = x === null;
const skipY = y === null;
const bounds = !bar || (skipX && skipY) ? false : getBarBounds(bar);
const bounds = !bar || (skipX && skipY) ? false : getBarBounds(bar, useFinalPosition);
return bounds
&& (skipX || x >= bounds.left && x <= bounds.right)
@ -165,33 +163,26 @@ export default class Rectangle extends Element {
ctx.restore();
}
inRange(mouseX, mouseY) {
return inRange(this, mouseX, mouseY);
inRange(mouseX, mouseY, useFinalPosition) {
return inRange(this, mouseX, mouseY, useFinalPosition);
}
inXRange(mouseX) {
return inRange(this, mouseX, null);
inXRange(mouseX, useFinalPosition) {
return inRange(this, mouseX, null, useFinalPosition);
}
inYRange(mouseY) {
return inRange(this, null, mouseY);
inYRange(mouseY, useFinalPosition) {
return inRange(this, null, mouseY, useFinalPosition);
}
getCenterPoint() {
const {x, y, base, horizontal} = this;
getCenterPoint(useFinalPosition) {
const {x, y, base, horizontal} = this.getProps(['x', 'y', 'base', 'horizontal', useFinalPosition]);
return {
x: horizontal ? (x + base) / 2 : x,
y: horizontal ? y : (y + base) / 2
};
}
tooltipPosition() {
return {
x: this.x,
y: this.y
};
}
getRange(axis) {
return axis === 'x' ? this.width / 2 : this.height / 2;
}

View File

@ -478,6 +478,7 @@ export class Tooltip extends Element {
this.height = undefined;
this.width = undefined;
this.caretX = undefined;
this.caretY = undefined;
this.labelColors = undefined;
this.labelTextColors = undefined;
@ -916,15 +917,22 @@ export class Tooltip extends Element {
const anims = me.$animations;
const animX = anims && anims.x;
const animY = anims && anims.y;
if (animX && animX.active() || animY && animY.active()) {
if (animX || animY) {
const position = positioners[options.position].call(me, me._active, me._eventPosition);
if (!position) {
return;
}
const size = me._size = getTooltipSize(me);
const positionAndSize = Object.assign({}, position, me._size);
const alignment = determineAlignment(chart, options, positionAndSize);
const point = getBackgroundPoint(options, positionAndSize, alignment, chart);
if (animX._to !== point.x || animY._to !== point.y) {
me.xAlign = alignment.xAlign;
me.yAlign = alignment.yAlign;
me.width = size.width;
me.height = size.height;
me.caretX = position.x;
me.caretY = position.y;
me._resolveAnimations().update(me, point);
}
}
@ -985,9 +993,10 @@ export class Tooltip extends Element {
/**
* Handle an event
* @param {IEvent} e - The event to handle
* @param {boolean} [replay] - This is a replayed event (from update)
* @returns {boolean} true if the tooltip changed
*/
handleEvent(e) {
handleEvent(e, replay) {
const me = this;
const options = me.options;
const lastActive = me._active || [];
@ -996,14 +1005,14 @@ export class Tooltip extends Element {
// Find Active Elements for tooltips
if (e.type !== 'mouseout') {
active = me._chart.getElementsAtEventForMode(e, options.mode, options);
active = me._chart.getElementsAtEventForMode(e, options.mode, options, replay);
if (options.reverse) {
active.reverse();
}
}
// Remember Last Actives
changed = !helpers._elementsEqual(active, lastActive);
changed = replay || !helpers._elementsEqual(active, lastActive);
// Only handle target event on tooltip change
if (changed) {
@ -1068,9 +1077,11 @@ export default {
plugins.notify(chart, 'afterTooltipDraw', [args]);
},
afterEvent(chart, e) {
afterEvent(chart, e, replay) {
if (chart.tooltip) {
chart.tooltip.handleEvent(e);
// If the event is replayed from `update`, we should evaluate with the final positions.
const useFinalPosition = replay;
chart.tooltip.handleEvent(e, useFinalPosition);
}
}
};

View File

@ -19,6 +19,21 @@ describe('Arc element tests', function() {
expect(arc.inRange(-1.0 * Math.sqrt(7), Math.sqrt(7))).toBe(false);
});
it ('should determine if in range, when full circle', function() {
// Mock out the arc as if the controller put it there
var arc = new Chart.elements.Arc({
startAngle: -Math.PI,
endAngle: Math.PI * 1.5,
x: 0,
y: 0,
innerRadius: 0,
outerRadius: 10,
circumference: Math.PI * 2
});
expect(arc.inRange(7, 7)).toBe(true);
});
it ('should get the tooltip position', function() {
// Mock out the arc as if the controller put it there
var arc = new Chart.elements.Arc({

View File

@ -31,8 +31,7 @@ describe('Chart.elements.Point', function() {
expect(point.tooltipPosition()).toEqual({
x: 10,
y: 15,
padding: 8
y: 15
});
});

View File

@ -107,14 +107,14 @@ describe('Default Configs', function() {
var expected = [{
text: 'label1',
fillStyle: 'red',
hidden: undefined,
hidden: false,
index: 0,
strokeStyle: '#000',
lineWidth: 2
}, {
text: 'label2',
fillStyle: 'green',
hidden: undefined,
hidden: false,
index: 1,
strokeStyle: '#000',
lineWidth: 2
@ -205,14 +205,14 @@ describe('Default Configs', function() {
var expected = [{
text: 'label1',
fillStyle: 'red',
hidden: undefined,
hidden: false,
index: 0,
strokeStyle: '#000',
lineWidth: 2
}, {
text: 'label2',
fillStyle: 'green',
hidden: undefined,
hidden: false,
index: 1,
strokeStyle: '#000',
lineWidth: 2