]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Limit active element changes to chartArea (#9970)
authorJukka Kurkela <jukka.kurkela@gmail.com>
Wed, 8 Dec 2021 14:44:45 +0000 (16:44 +0200)
committerGitHub <noreply@github.com>
Wed, 8 Dec 2021 14:44:45 +0000 (16:44 +0200)
* Limit active element changes to chartArea

* CC, remove duplicate ChartEvent interface

* CC2

src/core/core.controller.js
src/core/core.interaction.js
src/core/core.plugins.js
src/helpers/helpers.core.js
src/platform/platform.base.js
src/plugins/plugin.legend.js
src/plugins/plugin.tooltip.js
test/specs/core.controller.tests.js
test/specs/plugin.tooltip.tests.js
types/index.esm.d.ts

index 01fcd0975caf1e676a6206780f7ba6e91e8d6546..7b4df4137bf6eb8536c0d862ef876fbb40b0db22 100644 (file)
@@ -7,14 +7,14 @@ import PluginService from './core.plugins';
 import registry from './core.registry';
 import Config, {determineAxis, getIndexAxis} from './core.config';
 import {retinaScale, _isDomSupported} from '../helpers/helpers.dom';
-import {each, callback as callCallback, uid, valueOrDefault, _elementsEqual, isNullOrUndef, setsEqual, defined, isFunction} from '../helpers/helpers.core';
+import {each, callback as callCallback, uid, valueOrDefault, _elementsEqual, isNullOrUndef, setsEqual, defined, isFunction, _isClickEvent} from '../helpers/helpers.core';
 import {clearCanvas, clipArea, createContext, unclipArea, _isPointInArea} from '../helpers';
 // @ts-ignore
 import {version} from '../../package.json';
 import {debounce} from '../helpers/helpers.extras';
 
 /**
- * @typedef { import("../platform/platform.base").ChartEvent } ChartEvent
+ * @typedef { import('../../types/index.esm').ChartEvent } ChartEvent
  */
 
 const KNOWN_POSITIONS = ['top', 'bottom', 'left', 'right', 'chartArea'];
@@ -83,6 +83,22 @@ function moveNumericKeys(obj, start, move) {
   }
 }
 
+/**
+ * @param {ChartEvent} e
+ * @param {ChartEvent|null} lastEvent
+ * @param {boolean} inChartArea
+ * @param {boolean} isClick
+ * @returns {ChartEvent|null}
+ */
+function determineLastEvent(e, lastEvent, inChartArea, isClick) {
+  if (!inChartArea || e.type === 'mouseout') {
+    return null;
+  }
+  if (isClick) {
+    return lastEvent;
+  }
+  return e;
+}
 
 class Chart {
 
@@ -1109,14 +1125,19 @@ class Chart {
         * @private
         */
   _eventHandler(e, replay) {
-    const args = {event: e, replay, cancelable: true};
+    const args = {
+      event: e,
+      replay,
+      cancelable: true,
+      inChartArea: _isPointInArea(e, this.chartArea, this._minPadding)
+    };
     const eventFilter = (plugin) => (plugin.options.events || this.options.events).includes(e.native.type);
 
     if (this.notifyPlugins('beforeEvent', args, eventFilter) === false) {
       return;
     }
 
-    const changed = this._handleEvent(e, replay);
+    const changed = this._handleEvent(e, replay, args.inChartArea);
 
     args.cancelable = false;
     this.notifyPlugins('afterEvent', args, eventFilter);
@@ -1132,12 +1153,12 @@ class Chart {
         * Handle an event
         * @param {ChartEvent} e the event to handle
         * @param {boolean} [replay] - true if the event was replayed by `update`
+   * @param {boolean} [inChartArea] - true if the event is inside chartArea
         * @return {boolean} true if the chart needs to re-render
         * @private
         */
-  _handleEvent(e, replay) {
+  _handleEvent(e, replay, inChartArea) {
     const {_active: lastActive = [], options} = this;
-    const hoverOptions = options.hover;
 
     // If the event is replayed from `update`, we should evaluate with the final positions.
     //
@@ -1153,30 +1174,24 @@ class Chart {
     // This is done so we do not have to evaluate the active elements each animation frame
     // - it would be expensive.
     const useFinalPosition = replay;
+    const active = this._getActiveElements(e, lastActive, inChartArea, useFinalPosition);
+    const isClick = _isClickEvent(e);
+    const lastEvent = determineLastEvent(e, this._lastEvent, inChartArea, isClick);
 
-    let active = [];
-    let changed = false;
-    let lastEvent = null;
+    if (inChartArea) {
+      // Set _lastEvent to null while we are processing the event handlers.
+      // This prevents recursion if the handler calls chart.update()
+      this._lastEvent = null;
 
-    // Find Active Elements for hover and tooltips
-    if (e.type !== 'mouseout') {
-      active = this.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions, useFinalPosition);
-      lastEvent = e.type === 'click' ? this._lastEvent : e;
-    }
-    // Set _lastEvent to null while we are processing the event handlers.
-    // This prevents recursion if the handler calls chart.update()
-    this._lastEvent = null;
-
-    if (_isPointInArea(e, this.chartArea, this._minPadding)) {
       // Invoke onHover hook
       callCallback(options.onHover, [e, active, this], this);
 
-      if (e.type === 'mouseup' || e.type === 'click' || e.type === 'contextmenu') {
+      if (isClick) {
         callCallback(options.onClick, [e, active, this], this);
       }
     }
 
-    changed = !_elementsEqual(active, lastActive);
+    const changed = !_elementsEqual(active, lastActive);
     if (changed || replay) {
       this._active = active;
       this._updateHoverStyles(active, lastActive, replay);
@@ -1186,6 +1201,28 @@ class Chart {
 
     return changed;
   }
+
+  /**
+   * @param {ChartEvent} e - The event
+   * @param {import('../../types/index.esm').ActiveElement[]} lastActive - Previously active elements
+   * @param {boolean} inChartArea - Is the envent inside chartArea
+   * @param {boolean} useFinalPosition - Should the evaluation be done with current or final (after animation) element positions
+   * @returns {import('../../types/index.esm').ActiveElement[]} - The active elements
+   * @pravate
+   */
+  _getActiveElements(e, lastActive, inChartArea, useFinalPosition) {
+    if (e.type === 'mouseout') {
+      return [];
+    }
+
+    if (!inChartArea) {
+      // Let user control the active elements outside chartArea. Eg. using Legend.
+      return lastActive;
+    }
+
+    const hoverOptions = this.options.hover;
+    return this.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions, useFinalPosition);
+  }
 }
 
 // @ts-ignore
index 0a35020979807ccd816392e45511299ee9b93abb..662703d7f7b406df20bee52c3c1a05e182291009 100644 (file)
@@ -5,7 +5,7 @@ import {_angleBetween, getAngleFromPoint} from '../helpers/helpers.math';
 
 /**
  * @typedef { import("./core.controller").default } Chart
- * @typedef { import("../platform/platform.base").ChartEvent } ChartEvent
+ * @typedef { import("../../types/index.esm").ChartEvent } ChartEvent
  * @typedef {{axis?: string, intersect?: boolean}} InteractionOptions
  * @typedef {{datasetIndex: number, index: number, element: import("./core.element").default}} InteractionItem
  */
index 27a07bb0c91403484e8d1914d6dc14bae09d51b6..05d1e5c84fbe7b3823f6ff49ac9f3b9ab5a912de 100644 (file)
@@ -3,7 +3,7 @@ import {callback as callCallback, isNullOrUndef, valueOrDefault} from '../helper
 
 /**
  * @typedef { import("./core.controller").default } Chart
- * @typedef { import("../platform/platform.base").ChartEvent } ChartEvent
+ * @typedef { import("../../types/index.esm").ChartEvent } ChartEvent
  * @typedef { import("../plugins/plugin.tooltip").default } Tooltip
  */
 
index 0a5fcea1e6f6e6fd481c9efd1704baf39ba726a1..2663c8b834b25bdb79ceb3f7889bcc16fef77ec3 100644 (file)
@@ -340,3 +340,12 @@ export const setsEqual = (a, b) => {
 
   return true;
 };
+
+/**
+ * @param {import('../../types/index.esm').ChartEvent} e - The event
+ * @returns {boolean}
+ * @private
+ */
+export function _isClickEvent(e) {
+  return e.type === 'mouseup' || e.type === 'click' || e.type === 'contextmenu';
+}
index c29a5945a1c4c358d6c2fdc5d4c6b6f6d6c6dd10..1da169e9b3678a079fc17a64ac12bb48b3c644d5 100644 (file)
@@ -81,14 +81,3 @@ export default class BasePlatform {
     // no-op
   }
 }
-
-/**
- * @interface ChartEvent
- * @typedef {object} ChartEvent
- * @prop {string} type - The event type name, possible values are:
- * 'contextmenu', 'mouseenter', 'mousedown', 'mousemove', 'mouseup', 'mouseout',
- * 'click', 'dblclick', 'keydown', 'keypress', 'keyup' and 'resize'
- * @prop {*} native - The original native event (null for emulated events, e.g. 'resize')
- * @prop {number} x - The mouse x position, relative to the canvas (null for incompatible events)
- * @prop {number} y - The mouse y position, relative to the canvas (null for incompatible events)
- */
index d0bcb82761c37be984ea63a0edee845f6d828e48..7388bc714f11dc4c8ffc33d93d8d44320ccb847d 100644 (file)
@@ -10,7 +10,7 @@ import {
 import {_toLeftRightCenter, _alignStartEnd, _textX} from '../helpers/helpers.extras';
 import {toTRBLCorners} from '../helpers/helpers.options';
 /**
- * @typedef { import("../platform/platform.base").ChartEvent } ChartEvent
+ * @typedef { import("../../types/index.esm").ChartEvent } ChartEvent
  */
 
 const getBoxSize = (labelOpts, fontSize) => {
index 6d91c7ac8f979bb64f196f1ff3b26b3478e4931d..751569a8b7c88aa5678f0044896ad91093d76b75 100644 (file)
@@ -9,7 +9,7 @@ import {createContext, drawPoint} from '../helpers';
 
 /**
  * @typedef { import("../platform/platform.base").Chart } Chart
- * @typedef { import("../platform/platform.base").ChartEvent } ChartEvent
+ * @typedef { import("../../types/index.esm").ChartEvent } ChartEvent
  * @typedef { import("../../types/index.esm").ActiveElement } ActiveElement
  */
 
@@ -1012,21 +1012,13 @@ export class Tooltip extends Element {
         * Handle an event
         * @param {ChartEvent} e - The event to handle
         * @param {boolean} [replay] - This is a replayed event (from update)
+   * @param {boolean} [inChartArea] - The event is indide chartArea
         * @returns {boolean} true if the tooltip changed
         */
-  handleEvent(e, replay) {
+  handleEvent(e, replay, inChartArea = true) {
     const options = this.options;
     const lastActive = this._active || [];
-    let changed = false;
-    let active = [];
-
-    // Find Active Elements for tooltips
-    if (e.type !== 'mouseout') {
-      active = this.chart.getElementsAtEventForMode(e, options.mode, options, replay);
-      if (options.reverse) {
-        active.reverse();
-      }
-    }
+    const active = this._getActiveElements(e, lastActive, replay, inChartArea);
 
     // When there are multiple items shown, but the tooltip position is nearest mode
     // an update may need to be made because our position may have changed even though
@@ -1034,7 +1026,7 @@ export class Tooltip extends Element {
     const positionChanged = this._positionChanged(active, e);
 
     // Remember Last Actives
-    changed = replay || !_elementsEqual(active, lastActive) || positionChanged;
+    const changed = replay || !_elementsEqual(active, lastActive) || positionChanged;
 
     // Only handle target event on tooltip change
     if (changed) {
@@ -1053,6 +1045,37 @@ export class Tooltip extends Element {
     return changed;
   }
 
+  /**
+        * Helper for determining the active elements for event
+        * @param {ChartEvent} e - The event to handle
+   * @param {Element[]} lastActive - Previously active elements
+        * @param {boolean} [replay] - This is a replayed event (from update)
+   * @param {boolean} [inChartArea] - The event is indide chartArea
+        * @returns {Element[]} - Active elements
+   * @private
+        */
+  _getActiveElements(e, lastActive, replay, inChartArea) {
+    const options = this.options;
+
+    if (e.type === 'mouseout') {
+      return [];
+    }
+
+    if (!inChartArea) {
+      // Let user control the active elements outside chartArea. Eg. using Legend.
+      return lastActive;
+    }
+
+    // Find Active Elements for tooltips
+    const active = this.chart.getElementsAtEventForMode(e, options.mode, options, replay);
+
+    if (options.reverse) {
+      active.reverse();
+    }
+
+    return active;
+  }
+
   /**
         * Determine if the active elements + event combination changes the
         * tooltip position
@@ -1117,7 +1140,7 @@ export default {
     if (chart.tooltip) {
       // If the event is replayed from `update`, we should evaluate with the final positions.
       const useFinalPosition = args.replay;
-      if (chart.tooltip.handleEvent(args.event, useFinalPosition)) {
+      if (chart.tooltip.handleEvent(args.event, useFinalPosition, args.inChartArea)) {
         // notify chart about the change, so it will render
         args.changed = true;
       }
index 12d0df3016ab36be4dd43b7a0cb52b5d44737cfe..b0197b152abe7324c7e2f34781eea45ca842e173 100644 (file)
@@ -361,6 +361,39 @@ describe('Chart', function() {
       await jasmine.triggerMouseEvent(chart, 'mousemove', point);
       expect(chart.getActiveElements()).toEqual([]);
     });
+
+    it('should not change the active elements when outside chartArea, except for mouseout', async function() {
+      var chart = acquireChart({
+        type: 'line',
+        data: {
+          labels: ['A', 'B', 'C', 'D'],
+          datasets: [{
+            data: [10, 20, 30, 100],
+            hoverRadius: 0
+          }],
+        },
+        options: {
+          scales: {
+            x: {display: false},
+            y: {display: false}
+          },
+          layout: {
+            padding: 5
+          }
+        }
+      });
+
+      var point = chart.getDatasetMeta(0).data[0];
+
+      await jasmine.triggerMouseEvent(chart, 'mousemove', {x: point.x, y: point.y});
+      expect(chart.getActiveElements()).toEqual([{datasetIndex: 0, index: 0, element: point}]);
+
+      await jasmine.triggerMouseEvent(chart, 'mousemove', {x: 1, y: 1});
+      expect(chart.getActiveElements()).toEqual([{datasetIndex: 0, index: 0, element: point}]);
+
+      await jasmine.triggerMouseEvent(chart, 'mouseout', {x: 1, y: 1});
+      expect(chart.tooltip.getActiveElements()).toEqual([]);
+    });
   });
 
   describe('when merging scale options', function() {
index d181c870ca14db3256ba4353a9fabb0cf2e813a2..dedd7db88db288f897d95f6193ce1241b5dac886 100644 (file)
@@ -1556,6 +1556,38 @@ describe('Plugin.Tooltip', function() {
       expect(chart.tooltip.getActiveElements()[0].element).toBe(meta.data[0]);
     });
 
+    it('should not change the active elements on events outside chartArea, except for mouseout', async function() {
+      var chart = acquireChart({
+        type: 'line',
+        data: {
+          labels: ['A', 'B', 'C', 'D'],
+          datasets: [{
+            data: [10, 20, 30, 100]
+          }],
+        },
+        options: {
+          scales: {
+            x: {display: false},
+            y: {display: false}
+          },
+          layout: {
+            padding: 5
+          }
+        }
+      });
+
+      var point = chart.getDatasetMeta(0).data[0];
+
+      await jasmine.triggerMouseEvent(chart, 'mousemove', {x: point.x, y: point.y});
+      expect(chart.tooltip.getActiveElements()).toEqual([{datasetIndex: 0, index: 0, element: point}]);
+
+      await jasmine.triggerMouseEvent(chart, 'mousemove', {x: 1, y: 1});
+      expect(chart.tooltip.getActiveElements()).toEqual([{datasetIndex: 0, index: 0, element: point}]);
+
+      await jasmine.triggerMouseEvent(chart, 'mouseout', {x: 1, y: 1});
+      expect(chart.tooltip.getActiveElements()).toEqual([]);
+    });
+
     it('should update active elements when datasets are removed and added', async function() {
       var dataset = {
         label: 'Dataset 1',
index 9534d523c4bd5a042380c8d34d07e5e94241e15a..7d7721a7d4dadce5f3e1f9aaa8e72482583feacf 100644 (file)
@@ -1031,9 +1031,10 @@ export interface Plugin<TType extends ChartType = ChartType, O = AnyObject> exte
    * @param {object} args - The call arguments.
    * @param {ChartEvent} args.event - The event object.
    * @param {boolean} args.replay - True if this event is replayed from `Chart.update`
+   * @param {boolean} args.inChartArea - The event position is inside chartArea
    * @param {object} options - The plugin options.
    */
-  beforeEvent?(chart: Chart, args: { event: ChartEvent, replay: boolean, cancelable: true }, options: O): boolean | void;
+  beforeEvent?(chart: Chart, args: { event: ChartEvent, replay: boolean, cancelable: true, inChartArea: boolean }, options: O): boolean | void;
   /**
    * @desc Called after the `event` has been consumed. Note that this hook
    * will not be called if the `event` has been previously discarded.
@@ -1041,10 +1042,11 @@ export interface Plugin<TType extends ChartType = ChartType, O = AnyObject> exte
    * @param {object} args - The call arguments.
    * @param {ChartEvent} args.event - The event object.
    * @param {boolean} args.replay - True if this event is replayed from `Chart.update`
+   * @param {boolean} args.inChartArea - The event position is inside chartArea
    * @param {boolean} [args.changed] - Set to true if the plugin needs a render. Should only be changed to true, because this args object is passed through all plugins.
    * @param {object} options - The plugin options.
    */
-  afterEvent?(chart: Chart, args: { event: ChartEvent, replay: boolean, changed?: boolean, cancelable: false }, options: O): void;
+  afterEvent?(chart: Chart, args: { event: ChartEvent, replay: boolean, changed?: boolean, cancelable: false, inChartArea: boolean }, options: O): void;
   /**
    * @desc Called after the chart as been resized.
    * @param {Chart} chart - The chart instance.