this.chartArea = undefined;
this.data = undefined;
this.active = undefined;
- this.lastActive = undefined;
+ this.lastActive = [];
this._lastEvent = undefined;
/** @type {{resize?: function}} */
this._listeners = {};
// Replay last event from before update
if (me._lastEvent) {
- me._eventHandler(me._lastEvent);
+ me._eventHandler(me._lastEvent, true);
}
me.render();
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 [];
/**
* @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();
/**
* 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;
}
}
changed = !helpers._elementsEqual(me.active, me.lastActive);
- if (changed) {
+ if (changed || replay) {
me._updateHoverStyles();
}
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;
+ }
}
/**
* @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
*/
/**
* @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)) {
}
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});
}
};
* @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 = [];
}
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}];
* @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) {
* @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;
* @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);
},
/**
* @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);
},
/**
* @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;
}
});
* @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;
}
});
* @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
* @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
/**
* @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) {
}
}
- 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() {
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;
/**
* 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) {
};
}
-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)
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;
}
this.height = undefined;
this.width = undefined;
this.caretX = undefined;
+ this.caretY = undefined;
this.labelColors = undefined;
this.labelTextColors = undefined;
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);
}
}
/**
* 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 || [];
// 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) {
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);
}
}
};
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({
expect(point.tooltipPosition()).toEqual({
x: 10,
- y: 15,
- padding: 8
+ y: 15
});
});
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
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