Chart.js/src/core/core.controller.js
Lubomir Sotirov 38f85c98b5 Fix 3267 Add "onHover" functionality for legend (#3271)
Add "onHover" to the legend options that will hold a user defined function (default is null) and called when a "mousemove" event is registered on top of a label item, with same parameters as the "onClick" option.

Also introduced logic that determines if the type of event passed to the legend "handleEvent" function is one we can handle. Currently allowing "click" and "mousemove" events. If the event is not one of those we stop the function execution (this is for the sake of reusing the legend hitbox calculations).
2016-09-08 14:42:42 +02:00

688 lines
19 KiB
JavaScript

"use strict";
module.exports = function(Chart) {
var helpers = Chart.helpers;
//Create a dictionary of chart types, to allow for extension of existing types
Chart.types = {};
//Store a reference to each instance - allowing us to globally resize chart instances on window resize.
//Destroy method on the chart will remove the instance of the chart from this reference.
Chart.instances = {};
// Controllers available for dataset visualization eg. bar, line, slice, etc.
Chart.controllers = {};
/**
* @class Chart.Controller
* The main controller of a chart.
*/
Chart.Controller = function(instance) {
this.chart = instance;
this.config = instance.config;
this.options = this.config.options = helpers.configMerge(Chart.defaults.global, Chart.defaults[this.config.type], this.config.options || {});
this.id = helpers.uid();
Object.defineProperty(this, 'data', {
get: function() {
return this.config.data;
}
});
//Add the chart instance to the global namespace
Chart.instances[this.id] = this;
if (this.options.responsive) {
// Silent resize before chart draws
this.resize(true);
}
this.initialize();
return this;
};
helpers.extend(Chart.Controller.prototype, /** @lends Chart.Controller */ {
initialize: function() {
var me = this;
// Before init plugin notification
Chart.plugins.notify('beforeInit', [me]);
me.bindEvents();
// Make sure controllers are built first so that each dataset is bound to an axis before the scales
// are built
me.ensureScalesHaveIDs();
me.buildOrUpdateControllers();
me.buildScales();
me.updateLayout();
me.resetElements();
me.initToolTip();
me.update();
// After init plugin notification
Chart.plugins.notify('afterInit', [me]);
return me;
},
clear: function() {
helpers.clear(this.chart);
return this;
},
stop: function() {
// Stops any current animation loop occuring
Chart.animationService.cancelAnimation(this);
return this;
},
resize: function resize(silent) {
var me = this;
var chart = me.chart;
var canvas = chart.canvas;
var newWidth = helpers.getMaximumWidth(canvas);
var aspectRatio = chart.aspectRatio;
var newHeight = (me.options.maintainAspectRatio && isNaN(aspectRatio) === false && isFinite(aspectRatio) && aspectRatio !== 0) ? newWidth / aspectRatio : helpers.getMaximumHeight(canvas);
var sizeChanged = chart.width !== newWidth || chart.height !== newHeight;
if (!sizeChanged) {
return me;
}
canvas.width = chart.width = newWidth;
canvas.height = chart.height = newHeight;
helpers.retinaScale(chart);
// Notify any plugins about the resize
var newSize = { width: newWidth, height: newHeight };
Chart.plugins.notify('resize', [me, newSize]);
// Notify of resize
if (me.options.onResize) {
me.options.onResize(me, newSize);
}
if (!silent) {
me.stop();
me.update(me.options.responsiveAnimationDuration);
}
return me;
},
ensureScalesHaveIDs: function() {
var options = this.options;
var scalesOptions = options.scales || {};
var scaleOptions = options.scale;
helpers.each(scalesOptions.xAxes, function(xAxisOptions, index) {
xAxisOptions.id = xAxisOptions.id || ('x-axis-' + index);
});
helpers.each(scalesOptions.yAxes, function(yAxisOptions, index) {
yAxisOptions.id = yAxisOptions.id || ('y-axis-' + index);
});
if (scaleOptions) {
scaleOptions.id = scaleOptions.id || 'scale';
}
},
/**
* Builds a map of scale ID to scale object for future lookup.
*/
buildScales: function() {
var me = this;
var options = me.options;
var scales = me.scales = {};
var items = [];
if (options.scales) {
items = items.concat(
(options.scales.xAxes || []).map(function(xAxisOptions) {
return { options: xAxisOptions, dtype: 'category' }; }),
(options.scales.yAxes || []).map(function(yAxisOptions) {
return { options: yAxisOptions, dtype: 'linear' }; }));
}
if (options.scale) {
items.push({ options: options.scale, dtype: 'radialLinear', isDefault: true });
}
helpers.each(items, function(item) {
var scaleOptions = item.options;
var scaleType = helpers.getValueOrDefault(scaleOptions.type, item.dtype);
var scaleClass = Chart.scaleService.getScaleConstructor(scaleType);
if (!scaleClass) {
return;
}
var scale = new scaleClass({
id: scaleOptions.id,
options: scaleOptions,
ctx: me.chart.ctx,
chart: me
});
scales[scale.id] = scale;
// TODO(SB): I think we should be able to remove this custom case (options.scale)
// and consider it as a regular scale part of the "scales"" map only! This would
// make the logic easier and remove some useless? custom code.
if (item.isDefault) {
me.scale = scale;
}
});
Chart.scaleService.addScalesToLayout(this);
},
updateLayout: function() {
Chart.layoutService.update(this, this.chart.width, this.chart.height);
},
buildOrUpdateControllers: function() {
var me = this;
var types = [];
var newControllers = [];
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
var meta = me.getDatasetMeta(datasetIndex);
if (!meta.type) {
meta.type = dataset.type || me.config.type;
}
types.push(meta.type);
if (meta.controller) {
meta.controller.updateIndex(datasetIndex);
} else {
meta.controller = new Chart.controllers[meta.type](me, datasetIndex);
newControllers.push(meta.controller);
}
}, me);
if (types.length > 1) {
for (var i = 1; i < types.length; i++) {
if (types[i] !== types[i - 1]) {
me.isCombo = true;
break;
}
}
}
return newControllers;
},
resetElements: function() {
var me = this;
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
me.getDatasetMeta(datasetIndex).controller.reset();
}, me);
},
update: function update(animationDuration, lazy) {
var me = this;
Chart.plugins.notify('beforeUpdate', [me]);
// In case the entire data object changed
me.tooltip._data = me.data;
// Make sure dataset controllers are updated and new controllers are reset
var newControllers = me.buildOrUpdateControllers();
// Make sure all dataset controllers have correct meta data counts
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
me.getDatasetMeta(datasetIndex).controller.buildOrUpdateElements();
}, me);
Chart.layoutService.update(me, me.chart.width, me.chart.height);
// Apply changes to the dataets that require the scales to have been calculated i.e BorderColor chages
Chart.plugins.notify('afterScaleUpdate', [me]);
// Can only reset the new controllers after the scales have been updated
helpers.each(newControllers, function(controller) {
controller.reset();
});
me.updateDatasets();
// Do this before render so that any plugins that need final scale updates can use it
Chart.plugins.notify('afterUpdate', [me]);
me.render(animationDuration, lazy);
},
/**
* @method beforeDatasetsUpdate
* @description Called before all datasets are updated. If a plugin returns false,
* the datasets update will be cancelled until another chart update is triggered.
* @param {Object} instance the chart instance being updated.
* @returns {Boolean} false to cancel the datasets update.
* @memberof Chart.PluginBase
* @since version 2.1.5
* @instance
*/
/**
* @method afterDatasetsUpdate
* @description Called after all datasets have been updated. Note that this
* extension will not be called if the datasets update has been cancelled.
* @param {Object} instance the chart instance being updated.
* @memberof Chart.PluginBase
* @since version 2.1.5
* @instance
*/
/**
* Updates all datasets unless a plugin returns false to the beforeDatasetsUpdate
* extension, in which case no datasets will be updated and the afterDatasetsUpdate
* notification will be skipped.
* @protected
* @instance
*/
updateDatasets: function() {
var me = this;
var i, ilen;
if (Chart.plugins.notify('beforeDatasetsUpdate', [ me ])) {
for (i = 0, ilen = me.data.datasets.length; i < ilen; ++i) {
me.getDatasetMeta(i).controller.update();
}
Chart.plugins.notify('afterDatasetsUpdate', [ me ]);
}
},
render: function render(duration, lazy) {
var me = this;
Chart.plugins.notify('beforeRender', [me]);
var animationOptions = me.options.animation;
if (animationOptions && ((typeof duration !== 'undefined' && duration !== 0) || (typeof duration === 'undefined' && animationOptions.duration !== 0))) {
var animation = new Chart.Animation();
animation.numSteps = (duration || animationOptions.duration) / 16.66; //60 fps
animation.easing = animationOptions.easing;
// render function
animation.render = function(chartInstance, animationObject) {
var easingFunction = helpers.easingEffects[animationObject.easing];
var stepDecimal = animationObject.currentStep / animationObject.numSteps;
var easeDecimal = easingFunction(stepDecimal);
chartInstance.draw(easeDecimal, stepDecimal, animationObject.currentStep);
};
// user events
animation.onAnimationProgress = animationOptions.onProgress;
animation.onAnimationComplete = animationOptions.onComplete;
Chart.animationService.addAnimation(me, animation, duration, lazy);
} else {
me.draw();
if (animationOptions && animationOptions.onComplete && animationOptions.onComplete.call) {
animationOptions.onComplete.call(me);
}
}
return me;
},
draw: function(ease) {
var me = this;
var easingDecimal = ease || 1;
me.clear();
Chart.plugins.notify('beforeDraw', [me, easingDecimal]);
// Draw all the scales
helpers.each(me.boxes, function(box) {
box.draw(me.chartArea);
}, me);
if (me.scale) {
me.scale.draw();
}
Chart.plugins.notify('beforeDatasetsDraw', [me, easingDecimal]);
// Draw each dataset via its respective controller (reversed to support proper line stacking)
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
if (me.isDatasetVisible(datasetIndex)) {
me.getDatasetMeta(datasetIndex).controller.draw(ease);
}
}, me, true);
Chart.plugins.notify('afterDatasetsDraw', [me, easingDecimal]);
// Finally draw the tooltip
me.tooltip.transition(easingDecimal).draw();
Chart.plugins.notify('afterDraw', [me, easingDecimal]);
},
// Get the single element that was clicked on
// @return : An object containing the dataset index and element index of the matching element. Also contains the rectangle that was draw
getElementAtEvent: function(e) {
var me = this;
var eventPosition = helpers.getRelativePosition(e, me.chart);
var elementsArray = [];
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
if (me.isDatasetVisible(datasetIndex)) {
var meta = me.getDatasetMeta(datasetIndex);
helpers.each(meta.data, function(element) {
if (element.inRange(eventPosition.x, eventPosition.y)) {
elementsArray.push(element);
return elementsArray;
}
});
}
});
return elementsArray.slice(0, 1);
},
getElementsAtEvent: function(e) {
var me = this;
var eventPosition = helpers.getRelativePosition(e, me.chart);
var elementsArray = [];
var found = (function() {
if (me.data.datasets) {
for (var i = 0; i < me.data.datasets.length; i++) {
var meta = me.getDatasetMeta(i);
if (me.isDatasetVisible(i)) {
for (var j = 0; j < meta.data.length; j++) {
if (meta.data[j].inRange(eventPosition.x, eventPosition.y)) {
return meta.data[j];
}
}
}
}
}
}).call(me);
if (!found) {
return elementsArray;
}
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
if (me.isDatasetVisible(datasetIndex)) {
var meta = me.getDatasetMeta(datasetIndex),
element = meta.data[found._index];
if(element && !element._view.skip){
elementsArray.push(element);
}
}
}, me);
return elementsArray;
},
getElementsAtXAxis: function(e) {
var me = this;
var eventPosition = helpers.getRelativePosition(e, me.chart);
var elementsArray = [];
var found = (function() {
if (me.data.datasets) {
for (var i = 0; i < me.data.datasets.length; i++) {
var meta = me.getDatasetMeta(i);
if (me.isDatasetVisible(i)) {
for (var j = 0; j < meta.data.length; j++) {
if (meta.data[j].inLabelRange(eventPosition.x, eventPosition.y)) {
return meta.data[j];
}
}
}
}
}
}).call(me);
if (!found) {
return elementsArray;
}
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
if (me.isDatasetVisible(datasetIndex)) {
var meta = me.getDatasetMeta(datasetIndex);
var index = helpers.findIndex(meta.data, function (it) {
return found._model.x === it._model.x;
});
if(index !== -1 && !meta.data[index]._view.skip) {
elementsArray.push(meta.data[index]);
}
}
}, me);
return elementsArray;
},
getElementsAtEventForMode: function(e, mode) {
var me = this;
switch (mode) {
case 'single':
return me.getElementAtEvent(e);
case 'label':
return me.getElementsAtEvent(e);
case 'dataset':
return me.getDatasetAtEvent(e);
case 'x-axis':
return me.getElementsAtXAxis(e);
default:
return e;
}
},
getDatasetAtEvent: function(e) {
var elementsArray = this.getElementAtEvent(e);
if (elementsArray.length > 0) {
elementsArray = this.getDatasetMeta(elementsArray[0]._datasetIndex).data;
}
return elementsArray;
},
getDatasetMeta: function(datasetIndex) {
var me = this;
var dataset = me.data.datasets[datasetIndex];
if (!dataset._meta) {
dataset._meta = {};
}
var meta = dataset._meta[me.id];
if (!meta) {
meta = dataset._meta[me.id] = {
type: null,
data: [],
dataset: null,
controller: null,
hidden: null, // See isDatasetVisible() comment
xAxisID: null,
yAxisID: null
};
}
return meta;
},
getVisibleDatasetCount: function() {
var count = 0;
for (var i = 0, ilen = this.data.datasets.length; i<ilen; ++i) {
if (this.isDatasetVisible(i)) {
count++;
}
}
return count;
},
isDatasetVisible: function(datasetIndex) {
var meta = this.getDatasetMeta(datasetIndex);
// meta.hidden is a per chart dataset hidden flag override with 3 states: if true or false,
// the dataset.hidden value is ignored, else if null, the dataset hidden state is returned.
return typeof meta.hidden === 'boolean'? !meta.hidden : !this.data.datasets[datasetIndex].hidden;
},
generateLegend: function() {
return this.options.legendCallback(this);
},
destroy: function() {
var me = this;
me.stop();
me.clear();
helpers.unbindEvents(me, me.events);
helpers.removeResizeListener(me.chart.canvas.parentNode);
// Reset canvas height/width attributes
var canvas = me.chart.canvas;
canvas.width = me.chart.width;
canvas.height = me.chart.height;
// if we scaled the canvas in response to a devicePixelRatio !== 1, we need to undo that transform here
if (me.chart.originalDevicePixelRatio !== undefined) {
me.chart.ctx.scale(1 / me.chart.originalDevicePixelRatio, 1 / me.chart.originalDevicePixelRatio);
}
// Reset to the old style since it may have been changed by the device pixel ratio changes
canvas.style.width = me.chart.originalCanvasStyleWidth;
canvas.style.height = me.chart.originalCanvasStyleHeight;
Chart.plugins.notify('destroy', [me]);
delete Chart.instances[me.id];
},
toBase64Image: function() {
return this.chart.canvas.toDataURL.apply(this.chart.canvas, arguments);
},
initToolTip: function() {
var me = this;
me.tooltip = new Chart.Tooltip({
_chart: me.chart,
_chartInstance: me,
_data: me.data,
_options: me.options.tooltips
}, me);
},
bindEvents: function() {
var me = this;
helpers.bindEvents(me, me.options.events, function(evt) {
me.eventHandler(evt);
});
},
updateHoverStyle: function(elements, mode, enabled) {
var method = enabled? 'setHoverStyle' : 'removeHoverStyle';
var element, i, ilen;
switch (mode) {
case 'single':
elements = [ elements[0] ];
break;
case 'label':
case 'dataset':
case 'x-axis':
// elements = elements;
break;
default:
// unsupported mode
return;
}
for (i=0, ilen=elements.length; i<ilen; ++i) {
element = elements[i];
if (element) {
this.getDatasetMeta(element._datasetIndex).controller[method](element);
}
}
},
eventHandler: function eventHandler(e) {
var me = this;
var tooltip = me.tooltip;
var options = me.options || {};
var hoverOptions = options.hover;
var tooltipsOptions = options.tooltips;
me.lastActive = me.lastActive || [];
me.lastTooltipActive = me.lastTooltipActive || [];
// Find Active Elements for hover and tooltips
if (e.type === 'mouseout') {
me.active = [];
me.tooltipActive = [];
} else {
me.active = me.getElementsAtEventForMode(e, hoverOptions.mode);
me.tooltipActive = me.getElementsAtEventForMode(e, tooltipsOptions.mode);
}
// On Hover hook
if (hoverOptions.onHover) {
hoverOptions.onHover.call(me, me.active);
}
if (me.legend && me.legend.handleEvent) {
me.legend.handleEvent(e);
}
if (e.type === 'mouseup' || e.type === 'click') {
if (options.onClick) {
options.onClick.call(me, e, me.active);
}
}
// Remove styling for last active (even if it may still be active)
if (me.lastActive.length) {
me.updateHoverStyle(me.lastActive, hoverOptions.mode, false);
}
// Built in hover styling
if (me.active.length && hoverOptions.mode) {
me.updateHoverStyle(me.active, hoverOptions.mode, true);
}
// Built in Tooltips
if (tooltipsOptions.enabled || tooltipsOptions.custom) {
tooltip.initialize();
tooltip._active = me.tooltipActive;
tooltip.update(true);
}
// Hover animations
tooltip.pivot();
if (!me.animating) {
// If entering, leaving, or changing elements, animate the change via pivot
if (!helpers.arrayEquals(me.active, me.lastActive) ||
!helpers.arrayEquals(me.tooltipActive, me.lastTooltipActive)) {
me.stop();
if (tooltipsOptions.enabled || tooltipsOptions.custom) {
tooltip.update(true);
}
// We only need to render at this point. Updating will cause scales to be
// recomputed generating flicker & using more memory than necessary.
me.render(hoverOptions.animationDuration, true);
}
}
// Remember Last Actives
me.lastActive = me.active;
me.lastTooltipActive = me.tooltipActive;
return me;
}
});
};