Skip to content

Commit cfbb806

Browse files
authored
Configurable Tooltip Position Modes (chartjs#3453)
Adds new tooltip position option that allows configuring where a tooltip is displayed on the graph in relation to the elements that appear in it
1 parent 3260b61 commit cfbb806

File tree

4 files changed

+166
-84
lines changed

4 files changed

+166
-84
lines changed

docs/01-Chart-Configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ enabled | Boolean | true | Are tooltips enabled
214214
custom | Function | null | See [section](#advanced-usage-external-tooltips) below
215215
mode | String | 'nearest' | Sets which elements appear in the tooltip. See [Interaction Modes](#interaction-modes) for details
216216
intersect | Boolean | true | if true, the tooltip mode applies only when the mouse position intersects with an element. If false, the mode will be applied at all times.
217+
position | String | 'average' | The mode for positioning the tooltip. 'average' mode will place the tooltip at the average position of the items displayed in the tooltip. 'nearest' will place the tooltip at the position of the element closest to the event position. New modes can be defined by adding functions to the Chart.Tooltip.positioners map.
217218
itemSort | Function | undefined | Allows sorting of [tooltip items](#chart-configuration-tooltip-item-interface). Must implement at minimum a function that can be passed to [Array.prototype.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort). This function can also accept a third parameter that is the data object passed to the chart.
218219
backgroundColor | Color | 'rgba(0,0,0,0.8)' | Background color of the tooltip
219220
titleFontFamily | String | "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif" | Font family for tooltip title inherited from global font family

src/core/core.controller.js

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ module.exports = function(Chart) {
156156
me.chart = instance;
157157
me.config = config;
158158
me.options = config.options;
159+
me._bufferedRender = false;
159160

160161
// Add the chart instance to the global namespace
161162
Chart.instances[me.id] = me;
@@ -403,7 +404,9 @@ module.exports = function(Chart) {
403404
// Do this before render so that any plugins that need final scale updates can use it
404405
Chart.plugins.notify('afterUpdate', [me]);
405406

406-
me.render(animationDuration, lazy);
407+
if (!me._bufferedRender) {
408+
me.render(animationDuration, lazy);
409+
}
407410
},
408411

409412
/**
@@ -644,20 +647,6 @@ module.exports = function(Chart) {
644647
var method = enabled? 'setHoverStyle' : 'removeHoverStyle';
645648
var element, i, ilen;
646649

647-
switch (mode) {
648-
case 'single':
649-
elements = [elements[0]];
650-
break;
651-
case 'label':
652-
case 'dataset':
653-
case 'x-axis':
654-
// elements = elements;
655-
break;
656-
default:
657-
// unsupported mode
658-
return;
659-
}
660-
661650
for (i=0, ilen=elements.length; i<ilen; ++i) {
662651
element = elements[i];
663652
if (element) {
@@ -668,32 +657,54 @@ module.exports = function(Chart) {
668657

669658
eventHandler: function(e) {
670659
var me = this;
671-
var tooltip = me.tooltip;
660+
var hoverOptions = me.options.hover;
661+
662+
// Buffer any update calls so that renders do not occur
663+
me._bufferedRender = true;
664+
665+
var changed = me.handleEvent(e);
666+
changed |= me.legend.handleEvent(e);
667+
changed |= me.tooltip.handleEvent(e);
668+
669+
if (changed && !me.animating) {
670+
// If entering, leaving, or changing elements, animate the change via pivot
671+
me.stop();
672+
673+
// We only need to render at this point. Updating will cause scales to be
674+
// recomputed generating flicker & using more memory than necessary.
675+
me.render(hoverOptions.animationDuration, true);
676+
}
677+
678+
me._bufferedRender = false;
679+
return me;
680+
},
681+
682+
/**
683+
* Handle an event
684+
* @private
685+
* param e {Event} the event to handle
686+
* @return {Boolean} true if the chart needs to re-render
687+
*/
688+
handleEvent: function(e) {
689+
var me = this;
672690
var options = me.options || {};
673691
var hoverOptions = options.hover;
674-
var tooltipsOptions = options.tooltips;
692+
var changed = false;
675693

676694
me.lastActive = me.lastActive || [];
677-
me.lastTooltipActive = me.lastTooltipActive || [];
678695

679696
// Find Active Elements for hover and tooltips
680697
if (e.type === 'mouseout') {
681698
me.active = [];
682-
me.tooltipActive = [];
683699
} else {
684700
me.active = me.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions);
685-
me.tooltipActive = me.getElementsAtEventForMode(e, tooltipsOptions.mode, tooltipsOptions);
686701
}
687702

688703
// On Hover hook
689704
if (hoverOptions.onHover) {
690705
hoverOptions.onHover.call(me, me.active);
691706
}
692707

693-
if (me.legend && me.legend.handleEvent) {
694-
me.legend.handleEvent(e);
695-
}
696-
697708
if (e.type === 'mouseup' || e.type === 'click') {
698709
if (options.onClick) {
699710
options.onClick.call(me, e, me.active);
@@ -710,34 +721,12 @@ module.exports = function(Chart) {
710721
me.updateHoverStyle(me.active, hoverOptions.mode, true);
711722
}
712723

713-
// Built in Tooltips
714-
if (tooltipsOptions.enabled || tooltipsOptions.custom) {
715-
tooltip._active = me.tooltipActive;
716-
}
717-
718-
// Hover animations
719-
if (!me.animating) {
720-
// If entering, leaving, or changing elements, animate the change via pivot
721-
if (!helpers.arrayEquals(me.active, me.lastActive) ||
722-
!helpers.arrayEquals(me.tooltipActive, me.lastTooltipActive)) {
723-
724-
me.stop();
725-
726-
if (tooltipsOptions.enabled || tooltipsOptions.custom) {
727-
tooltip.update(true);
728-
tooltip.pivot();
729-
}
730-
731-
// We only need to render at this point. Updating will cause scales to be
732-
// recomputed generating flicker & using more memory than necessary.
733-
me.render(hoverOptions.animationDuration, true);
734-
}
735-
}
724+
changed = !helpers.arrayEquals(me.active, me.lastActive);
736725

737726
// Remember Last Actives
738727
me.lastActive = me.active;
739-
me.lastTooltipActive = me.tooltipActive;
740-
return me;
728+
729+
return changed;
741730
}
742731
});
743732
};

src/core/core.legend.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,11 +426,17 @@ module.exports = function(Chart) {
426426
}
427427
},
428428

429-
// Handle an event
429+
/**
430+
* Handle an event
431+
* @private
432+
* @param e {Event} the event to handle
433+
* @return {Boolean} true if a change occured
434+
*/
430435
handleEvent: function(e) {
431436
var me = this;
432437
var opts = me.options;
433438
var type = e.type === 'mouseup' ? 'click' : e.type;
439+
var changed = false;
434440

435441
if (type === 'mousemove') {
436442
if (!opts.onHover) {
@@ -458,14 +464,18 @@ module.exports = function(Chart) {
458464
// Touching an element
459465
if (type === 'click') {
460466
opts.onClick.call(me, e, me.legendItems[i]);
467+
changed = true;
461468
break;
462469
} else if (type === 'mousemove') {
463470
opts.onHover.call(me, e, me.legendItems[i]);
471+
changed = true;
464472
break;
465473
}
466474
}
467475
}
468476
}
477+
478+
return changed;
469479
}
470480
});
471481

src/core/core.tooltip.js

Lines changed: 116 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module.exports = function(Chart) {
1616
enabled: true,
1717
custom: null,
1818
mode: 'nearest',
19+
position: 'average',
1920
intersect: true,
2021
backgroundColor: 'rgba(0,0,0,0.8)',
2122
titleFontStyle: 'bold',
@@ -104,39 +105,6 @@ module.exports = function(Chart) {
104105
return base;
105106
}
106107

107-
function getAveragePosition(elements) {
108-
if (!elements.length) {
109-
return false;
110-
}
111-
112-
var i, len;
113-
var xPositions = [];
114-
var yPositions = [];
115-
116-
for (i = 0, len = elements.length; i < len; ++i) {
117-
var el = elements[i];
118-
if (el && el.hasValue()) {
119-
var pos = el.tooltipPosition();
120-
xPositions.push(pos.x);
121-
yPositions.push(pos.y);
122-
}
123-
}
124-
125-
var x = 0,
126-
y = 0;
127-
for (i = 0; i < xPositions.length; ++i) {
128-
if (xPositions[i]) {
129-
x += xPositions[i];
130-
y += yPositions[i];
131-
}
132-
}
133-
134-
return {
135-
x: Math.round(x / xPositions.length),
136-
y: Math.round(y / xPositions.length)
137-
};
138-
}
139-
140108
// Private helper to create a tooltip iteam model
141109
// @param element : the chart element (point, arc, bar) to create the tooltip item for
142110
// @return : new tooltip item
@@ -504,7 +472,7 @@ module.exports = function(Chart) {
504472
model.opacity = 1;
505473

506474
var labelColors = [],
507-
tooltipPosition = getAveragePosition(active);
475+
tooltipPosition = Chart.Tooltip.positioners[opts.position](active, me._eventPosition);
508476

509477
var tooltipItems = [];
510478
for (i = 0, len = active.length; i < len; ++i) {
@@ -770,6 +738,120 @@ module.exports = function(Chart) {
770738
// Footer
771739
this.drawFooter(pt, vm, ctx, opacity);
772740
}
741+
},
742+
743+
/**
744+
* Handle an event
745+
* @private
746+
* @param e {Event} the event to handle
747+
* @returns {Boolean} true if the tooltip changed
748+
*/
749+
handleEvent: function(e) {
750+
var me = this;
751+
var options = me._options;
752+
var changed = false;
753+
754+
me._lastActive = me._lastActive || [];
755+
756+
// Find Active Elements for tooltips
757+
if (e.type === 'mouseout') {
758+
me._active = [];
759+
} else {
760+
me._active = me._chartInstance.getElementsAtEventForMode(e, options.mode, options);
761+
}
762+
763+
// Remember Last Actives
764+
changed = !helpers.arrayEquals(me._active, me._lastActive);
765+
me._lastActive = me._active;
766+
767+
if (options.enabled || options.custom) {
768+
me._eventPosition = helpers.getRelativePosition(e, me._chart);
769+
770+
var model = me._model;
771+
me.update(true);
772+
me.pivot();
773+
774+
// See if our tooltip position changed
775+
changed |= (model.x !== me._model.x) || (model.y !== me._model.y);
776+
}
777+
778+
return changed;
773779
}
774780
});
781+
782+
/**
783+
* @namespace Chart.Tooltip.positioners
784+
*/
785+
Chart.Tooltip.positioners = {
786+
/**
787+
* Average mode places the tooltip at the average position of the elements shown
788+
* @function Chart.Tooltip.positioners.average
789+
* @param elements {ChartElement[]} the elements being displayed in the tooltip
790+
* @returns {Point} tooltip position
791+
*/
792+
average: function(elements) {
793+
if (!elements.length) {
794+
return false;
795+
}
796+
797+
var i, len;
798+
var x = 0;
799+
var y = 0;
800+
var count = 0;
801+
802+
for (i = 0, len = elements.length; i < len; ++i) {
803+
var el = elements[i];
804+
if (el && el.hasValue()) {
805+
var pos = el.tooltipPosition();
806+
x += pos.x;
807+
y += pos.y;
808+
++count;
809+
}
810+
}
811+
812+
return {
813+
x: Math.round(x / count),
814+
y: Math.round(y / count)
815+
};
816+
},
817+
818+
/**
819+
* Gets the tooltip position nearest of the item nearest to the event position
820+
* @function Chart.Tooltip.positioners.nearest
821+
* @param elements {Chart.Element[]} the tooltip elements
822+
* @param eventPosition {Point} the position of the event in canvas coordinates
823+
* @returns {Point} the tooltip position
824+
*/
825+
nearest: function(elements, eventPosition) {
826+
var x = eventPosition.x;
827+
var y = eventPosition.y;
828+
829+
var nearestElement;
830+
var minDistance = Number.POSITIVE_INFINITY;
831+
var i, len;
832+
for (i = 0, len = elements.length; i < len; ++i) {
833+
var el = elements[i];
834+
if (el && el.hasValue()) {
835+
var center = el.getCenterPoint();
836+
var d = helpers.distanceBetweenPoints(eventPosition, center);
837+
838+
if (d < minDistance) {
839+
minDistance = d;
840+
nearestElement = el;
841+
}
842+
}
843+
}
844+
845+
if (nearestElement) {
846+
var tp = nearestElement.tooltipPosition();
847+
x = tp.x;
848+
y = tp.y;
849+
}
850+
851+
return {
852+
x: x,
853+
y: y
854+
};
855+
}
856+
};
775857
};

0 commit comments

Comments
 (0)