mirror of
https://github.com/chartjs/Chart.js.git
synced 2025-12-08 20:36:08 +00:00
1208 lines
31 KiB
JavaScript
1208 lines
31 KiB
JavaScript
import Animations from '../core/core.animations';
|
|
import defaults from '../core/core.defaults';
|
|
import Element from '../core/core.element';
|
|
import {valueOrDefault, each, noop, isNullOrUndef, isArray, _elementsEqual, merge} from '../helpers/helpers.core';
|
|
import {getRtlAdapter, overrideTextDirection, restoreTextDirection} from '../helpers/helpers.rtl';
|
|
import {distanceBetweenPoints} from '../helpers/helpers.math';
|
|
import {toFont} from '../helpers/helpers.options';
|
|
import {drawPoint} from '../helpers';
|
|
|
|
/**
|
|
* @typedef { import("../platform/platform.base").IEvent } IEvent
|
|
*/
|
|
|
|
const positioners = {
|
|
/**
|
|
* Average mode places the tooltip at the average position of the elements shown
|
|
* @function Chart.Tooltip.positioners.average
|
|
* @param items {object[]} the items being displayed in the tooltip
|
|
* @returns {object} tooltip position
|
|
*/
|
|
average(items) {
|
|
if (!items.length) {
|
|
return false;
|
|
}
|
|
|
|
let i, len;
|
|
let x = 0;
|
|
let y = 0;
|
|
let count = 0;
|
|
|
|
for (i = 0, len = items.length; i < len; ++i) {
|
|
const el = items[i].element;
|
|
if (el && el.hasValue()) {
|
|
const pos = el.tooltipPosition();
|
|
x += pos.x;
|
|
y += pos.y;
|
|
++count;
|
|
}
|
|
}
|
|
|
|
return {
|
|
x: x / count,
|
|
y: y / count
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Gets the tooltip position nearest of the item nearest to the event position
|
|
* @function Chart.Tooltip.positioners.nearest
|
|
* @param items {object[]} the tooltip items
|
|
* @param eventPosition {object} the position of the event in canvas coordinates
|
|
* @returns {object} the tooltip position
|
|
*/
|
|
nearest(items, eventPosition) {
|
|
let x = eventPosition.x;
|
|
let y = eventPosition.y;
|
|
let minDistance = Number.POSITIVE_INFINITY;
|
|
let i, len, nearestElement;
|
|
|
|
for (i = 0, len = items.length; i < len; ++i) {
|
|
const el = items[i].element;
|
|
if (el && el.hasValue()) {
|
|
const center = el.getCenterPoint();
|
|
const d = distanceBetweenPoints(eventPosition, center);
|
|
|
|
if (d < minDistance) {
|
|
minDistance = d;
|
|
nearestElement = el;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nearestElement) {
|
|
const tp = nearestElement.tooltipPosition();
|
|
x = tp.x;
|
|
y = tp.y;
|
|
}
|
|
|
|
return {
|
|
x,
|
|
y
|
|
};
|
|
}
|
|
};
|
|
|
|
// Helper to push or concat based on if the 2nd parameter is an array or not
|
|
function pushOrConcat(base, toPush) {
|
|
if (toPush) {
|
|
if (isArray(toPush)) {
|
|
// base = base.concat(toPush);
|
|
Array.prototype.push.apply(base, toPush);
|
|
} else {
|
|
base.push(toPush);
|
|
}
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
/**
|
|
* Returns array of strings split by newline
|
|
* @param {*} str - The value to split by newline.
|
|
* @returns {string|string[]} value if newline present - Returned from String split() method
|
|
* @function
|
|
*/
|
|
function splitNewlines(str) {
|
|
if ((typeof str === 'string' || str instanceof String) && str.indexOf('\n') > -1) {
|
|
return str.split('\n');
|
|
}
|
|
return str;
|
|
}
|
|
|
|
|
|
/**
|
|
* Private helper to create a tooltip item model
|
|
* @param item - {element, index, datasetIndex} to create the tooltip item for
|
|
* @return new tooltip item
|
|
*/
|
|
function createTooltipItem(chart, item) {
|
|
const {element, datasetIndex, index} = item;
|
|
const controller = chart.getDatasetMeta(datasetIndex).controller;
|
|
const {label, value} = controller.getLabelAndValue(index);
|
|
|
|
return {
|
|
chart,
|
|
label,
|
|
dataPoint: controller.getParsed(index),
|
|
formattedValue: value,
|
|
dataset: controller.getDataset(),
|
|
dataIndex: index,
|
|
datasetIndex,
|
|
element
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to get the reset model for the tooltip
|
|
* @param options {object} the tooltip options
|
|
* @param fallbackFont {object} the fallback font options
|
|
*/
|
|
function resolveOptions(options, fallbackFont) {
|
|
|
|
options = merge(Object.create(null), [defaults.plugins.tooltip, options]);
|
|
|
|
options.bodyFont = toFont(options.bodyFont, fallbackFont);
|
|
options.titleFont = toFont(options.titleFont, fallbackFont);
|
|
options.footerFont = toFont(options.footerFont, fallbackFont);
|
|
|
|
options.boxHeight = valueOrDefault(options.boxHeight, options.bodyFont.size);
|
|
options.boxWidth = valueOrDefault(options.boxWidth, options.bodyFont.size);
|
|
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* Get the size of the tooltip
|
|
*/
|
|
function getTooltipSize(tooltip) {
|
|
const ctx = tooltip._chart.ctx;
|
|
const {body, footer, options, title} = tooltip;
|
|
const {bodyFont, footerFont, titleFont, boxWidth, boxHeight} = options;
|
|
const titleLineCount = title.length;
|
|
const footerLineCount = footer.length;
|
|
const bodyLineItemCount = body.length;
|
|
|
|
let height = options.yPadding * 2; // Tooltip Padding
|
|
let width = 0;
|
|
|
|
// Count of all lines in the body
|
|
let combinedBodyLength = body.reduce((count, bodyItem) => count + bodyItem.before.length + bodyItem.lines.length + bodyItem.after.length, 0);
|
|
combinedBodyLength += tooltip.beforeBody.length + tooltip.afterBody.length;
|
|
|
|
if (titleLineCount) {
|
|
height += titleLineCount * titleFont.size
|
|
+ (titleLineCount - 1) * options.titleSpacing
|
|
+ options.titleMarginBottom;
|
|
}
|
|
if (combinedBodyLength) {
|
|
// Body lines may include some extra height depending on boxHeight
|
|
const bodyLineHeight = options.displayColors ? Math.max(boxHeight, bodyFont.size) : bodyFont.size;
|
|
height += bodyLineItemCount * bodyLineHeight
|
|
+ (combinedBodyLength - bodyLineItemCount) * bodyFont.size
|
|
+ (combinedBodyLength - 1) * options.bodySpacing;
|
|
}
|
|
if (footerLineCount) {
|
|
height += options.footerMarginTop
|
|
+ footerLineCount * footerFont.size
|
|
+ (footerLineCount - 1) * options.footerSpacing;
|
|
}
|
|
|
|
// Title width
|
|
let widthPadding = 0;
|
|
const maxLineWidth = function(line) {
|
|
width = Math.max(width, ctx.measureText(line).width + widthPadding);
|
|
};
|
|
|
|
ctx.save();
|
|
|
|
ctx.font = titleFont.string;
|
|
each(tooltip.title, maxLineWidth);
|
|
|
|
// Body width
|
|
ctx.font = bodyFont.string;
|
|
each(tooltip.beforeBody.concat(tooltip.afterBody), maxLineWidth);
|
|
|
|
// Body lines may include some extra width due to the color box
|
|
widthPadding = options.displayColors ? (boxWidth + 2) : 0;
|
|
each(body, (bodyItem) => {
|
|
each(bodyItem.before, maxLineWidth);
|
|
each(bodyItem.lines, maxLineWidth);
|
|
each(bodyItem.after, maxLineWidth);
|
|
});
|
|
|
|
// Reset back to 0
|
|
widthPadding = 0;
|
|
|
|
// Footer width
|
|
ctx.font = footerFont.string;
|
|
each(tooltip.footer, maxLineWidth);
|
|
|
|
ctx.restore();
|
|
|
|
// Add padding
|
|
width += 2 * options.xPadding;
|
|
|
|
return {width, height};
|
|
}
|
|
|
|
/**
|
|
* Helper to get the alignment of a tooltip given the size
|
|
*/
|
|
function determineAlignment(chart, options, size) {
|
|
const {x, y, width, height} = size;
|
|
const chartArea = chart.chartArea;
|
|
let xAlign = 'center';
|
|
let yAlign = 'center';
|
|
|
|
if (y < height / 2) {
|
|
yAlign = 'top';
|
|
} else if (y > (chart.height - height / 2)) {
|
|
yAlign = 'bottom';
|
|
}
|
|
|
|
let lf, rf; // functions to determine left, right alignment
|
|
const midX = (chartArea.left + chartArea.right) / 2;
|
|
const midY = (chartArea.top + chartArea.bottom) / 2;
|
|
|
|
if (yAlign === 'center') {
|
|
lf = (value) => value <= midX;
|
|
rf = (value) => value > midX;
|
|
} else {
|
|
lf = (value) => value <= (width / 2);
|
|
rf = (value) => value >= (chart.width - (width / 2));
|
|
}
|
|
|
|
// functions to determine if left/right alignment causes tooltip to go outside chart
|
|
const olf = (value) => value + width + options.caretSize + options.caretPadding > chart.width;
|
|
const orf = (value) => value - width - options.caretSize - options.caretPadding < 0;
|
|
// function to get the y alignment if the tooltip goes outside of the left or right edges
|
|
const yf = (value) => value <= midY ? 'top' : 'bottom';
|
|
|
|
if (lf(x)) {
|
|
xAlign = 'left';
|
|
|
|
// Is tooltip too wide and goes over the right side of the chart.?
|
|
if (olf(x)) {
|
|
xAlign = 'center';
|
|
yAlign = yf(y);
|
|
}
|
|
} else if (rf(x)) {
|
|
xAlign = 'right';
|
|
|
|
// Is tooltip too wide and goes outside left edge of canvas?
|
|
if (orf(x)) {
|
|
xAlign = 'center';
|
|
yAlign = yf(y);
|
|
}
|
|
}
|
|
|
|
return {
|
|
xAlign: options.xAlign ? options.xAlign : xAlign,
|
|
yAlign: options.yAlign ? options.yAlign : yAlign
|
|
};
|
|
}
|
|
|
|
function alignX(size, xAlign, chartWidth) {
|
|
// eslint-disable-next-line prefer-const
|
|
let {x, width} = size;
|
|
if (xAlign === 'right') {
|
|
x -= width;
|
|
} else if (xAlign === 'center') {
|
|
x -= (width / 2);
|
|
if (x + width > chartWidth) {
|
|
x = chartWidth - width;
|
|
}
|
|
if (x < 0) {
|
|
x = 0;
|
|
}
|
|
}
|
|
return x;
|
|
}
|
|
|
|
function alignY(size, yAlign, paddingAndSize) {
|
|
// eslint-disable-next-line prefer-const
|
|
let {y, height} = size;
|
|
if (yAlign === 'top') {
|
|
y += paddingAndSize;
|
|
} else if (yAlign === 'bottom') {
|
|
y -= height + paddingAndSize;
|
|
} else {
|
|
y -= (height / 2);
|
|
}
|
|
return y;
|
|
}
|
|
|
|
/**
|
|
* Helper to get the location a tooltip needs to be placed at given the initial position (via the vm) and the size and alignment
|
|
*/
|
|
function getBackgroundPoint(options, size, alignment, chart) {
|
|
const {caretSize, caretPadding, cornerRadius} = options;
|
|
const {xAlign, yAlign} = alignment;
|
|
const paddingAndSize = caretSize + caretPadding;
|
|
const radiusAndPadding = cornerRadius + caretPadding;
|
|
|
|
let x = alignX(size, xAlign, chart.width);
|
|
const y = alignY(size, yAlign, paddingAndSize);
|
|
|
|
if (yAlign === 'center') {
|
|
if (xAlign === 'left') {
|
|
x += paddingAndSize;
|
|
} else if (xAlign === 'right') {
|
|
x -= paddingAndSize;
|
|
}
|
|
} else if (xAlign === 'left') {
|
|
x -= radiusAndPadding;
|
|
} else if (xAlign === 'right') {
|
|
x += radiusAndPadding;
|
|
}
|
|
|
|
return {x, y};
|
|
}
|
|
|
|
function getAlignedX(tooltip, align) {
|
|
const options = tooltip.options;
|
|
return align === 'center'
|
|
? tooltip.x + tooltip.width / 2
|
|
: align === 'right'
|
|
? tooltip.x + tooltip.width - options.xPadding
|
|
: tooltip.x + options.xPadding;
|
|
}
|
|
|
|
/**
|
|
* Helper to build before and after body lines
|
|
*/
|
|
function getBeforeAfterBodyLines(callback) {
|
|
return pushOrConcat([], splitNewlines(callback));
|
|
}
|
|
|
|
export class Tooltip extends Element {
|
|
constructor(config) {
|
|
super();
|
|
|
|
this.opacity = 0;
|
|
this._active = [];
|
|
this._chart = config._chart;
|
|
this._eventPosition = undefined;
|
|
this._size = undefined;
|
|
this._cachedAnimations = undefined;
|
|
this.$animations = undefined;
|
|
this.options = undefined;
|
|
this.dataPoints = undefined;
|
|
this.title = undefined;
|
|
this.beforeBody = undefined;
|
|
this.body = undefined;
|
|
this.afterBody = undefined;
|
|
this.footer = undefined;
|
|
this.xAlign = undefined;
|
|
this.yAlign = undefined;
|
|
this.x = undefined;
|
|
this.y = undefined;
|
|
this.height = undefined;
|
|
this.width = undefined;
|
|
this.caretX = undefined;
|
|
this.caretY = undefined;
|
|
this.labelColors = undefined;
|
|
this.labelPointStyles = undefined;
|
|
this.labelTextColors = undefined;
|
|
|
|
this.initialize();
|
|
}
|
|
|
|
initialize() {
|
|
const me = this;
|
|
const chartOpts = me._chart.options;
|
|
me.options = resolveOptions(chartOpts.tooltips, chartOpts.font);
|
|
me._cachedAnimations = undefined;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_resolveAnimations() {
|
|
const me = this;
|
|
const cached = me._cachedAnimations;
|
|
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const chart = me._chart;
|
|
const options = me.options;
|
|
const opts = options.enabled && chart.options.animation && options.animation;
|
|
const animations = new Animations(me._chart, opts);
|
|
me._cachedAnimations = Object.freeze(animations);
|
|
|
|
return animations;
|
|
}
|
|
|
|
getTitle(context) {
|
|
const me = this;
|
|
const opts = me.options;
|
|
const callbacks = opts.callbacks;
|
|
|
|
const beforeTitle = callbacks.beforeTitle.apply(me, [context]);
|
|
const title = callbacks.title.apply(me, [context]);
|
|
const afterTitle = callbacks.afterTitle.apply(me, [context]);
|
|
|
|
let lines = [];
|
|
lines = pushOrConcat(lines, splitNewlines(beforeTitle));
|
|
lines = pushOrConcat(lines, splitNewlines(title));
|
|
lines = pushOrConcat(lines, splitNewlines(afterTitle));
|
|
|
|
return lines;
|
|
}
|
|
|
|
getBeforeBody(tooltipItems) {
|
|
return getBeforeAfterBodyLines(this.options.callbacks.beforeBody.apply(this, [tooltipItems]));
|
|
}
|
|
|
|
getBody(tooltipItems) {
|
|
const me = this;
|
|
const callbacks = me.options.callbacks;
|
|
const bodyItems = [];
|
|
|
|
each(tooltipItems, (context) => {
|
|
const bodyItem = {
|
|
before: [],
|
|
lines: [],
|
|
after: []
|
|
};
|
|
pushOrConcat(bodyItem.before, splitNewlines(callbacks.beforeLabel.call(me, context)));
|
|
pushOrConcat(bodyItem.lines, callbacks.label.call(me, context));
|
|
pushOrConcat(bodyItem.after, splitNewlines(callbacks.afterLabel.call(me, context)));
|
|
|
|
bodyItems.push(bodyItem);
|
|
});
|
|
|
|
return bodyItems;
|
|
}
|
|
|
|
getAfterBody(tooltipItems) {
|
|
return getBeforeAfterBodyLines(this.options.callbacks.afterBody.apply(this, [tooltipItems]));
|
|
}
|
|
|
|
// Get the footer and beforeFooter and afterFooter lines
|
|
getFooter(tooltipItems) {
|
|
const me = this;
|
|
const callbacks = me.options.callbacks;
|
|
|
|
const beforeFooter = callbacks.beforeFooter.apply(me, [tooltipItems]);
|
|
const footer = callbacks.footer.apply(me, [tooltipItems]);
|
|
const afterFooter = callbacks.afterFooter.apply(me, [tooltipItems]);
|
|
|
|
let lines = [];
|
|
lines = pushOrConcat(lines, splitNewlines(beforeFooter));
|
|
lines = pushOrConcat(lines, splitNewlines(footer));
|
|
lines = pushOrConcat(lines, splitNewlines(afterFooter));
|
|
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_createItems() {
|
|
const me = this;
|
|
const active = me._active;
|
|
const options = me.options;
|
|
const data = me._chart.data;
|
|
const labelColors = [];
|
|
const labelPointStyles = [];
|
|
const labelTextColors = [];
|
|
let tooltipItems = [];
|
|
let i, len;
|
|
|
|
for (i = 0, len = active.length; i < len; ++i) {
|
|
tooltipItems.push(createTooltipItem(me._chart, active[i]));
|
|
}
|
|
|
|
// If the user provided a filter function, use it to modify the tooltip items
|
|
if (options.filter) {
|
|
tooltipItems = tooltipItems.filter((element, index, array) => options.filter(element, index, array, data));
|
|
}
|
|
|
|
// If the user provided a sorting function, use it to modify the tooltip items
|
|
if (options.itemSort) {
|
|
tooltipItems = tooltipItems.sort((a, b) => options.itemSort(a, b, data));
|
|
}
|
|
|
|
// Determine colors for boxes
|
|
each(tooltipItems, (context) => {
|
|
labelColors.push(options.callbacks.labelColor.call(me, context));
|
|
labelPointStyles.push(options.callbacks.labelPointStyle.call(me, context));
|
|
labelTextColors.push(options.callbacks.labelTextColor.call(me, context));
|
|
});
|
|
|
|
me.labelColors = labelColors;
|
|
me.labelPointStyles = labelPointStyles;
|
|
me.labelTextColors = labelTextColors;
|
|
me.dataPoints = tooltipItems;
|
|
return tooltipItems;
|
|
}
|
|
|
|
update(changed) {
|
|
const me = this;
|
|
const options = me.options;
|
|
const active = me._active;
|
|
let properties;
|
|
|
|
if (!active.length) {
|
|
if (me.opacity !== 0) {
|
|
properties = {
|
|
opacity: 0
|
|
};
|
|
}
|
|
} else {
|
|
const position = positioners[options.position].call(me, active, me._eventPosition);
|
|
const tooltipItems = me._createItems();
|
|
|
|
me.title = me.getTitle(tooltipItems);
|
|
me.beforeBody = me.getBeforeBody(tooltipItems);
|
|
me.body = me.getBody(tooltipItems);
|
|
me.afterBody = me.getAfterBody(tooltipItems);
|
|
me.footer = me.getFooter(tooltipItems);
|
|
|
|
const size = me._size = getTooltipSize(me);
|
|
const positionAndSize = Object.assign({}, position, size);
|
|
const alignment = determineAlignment(me._chart, options, positionAndSize);
|
|
const backgroundPoint = getBackgroundPoint(options, positionAndSize, alignment, me._chart);
|
|
|
|
me.xAlign = alignment.xAlign;
|
|
me.yAlign = alignment.yAlign;
|
|
|
|
properties = {
|
|
opacity: 1,
|
|
x: backgroundPoint.x,
|
|
y: backgroundPoint.y,
|
|
width: size.width,
|
|
height: size.height,
|
|
caretX: position.x,
|
|
caretY: position.y
|
|
};
|
|
}
|
|
|
|
if (properties) {
|
|
me._resolveAnimations().update(me, properties);
|
|
}
|
|
|
|
if (changed && options.custom) {
|
|
options.custom.call(me, {chart: me._chart, tooltip: me});
|
|
}
|
|
}
|
|
|
|
drawCaret(tooltipPoint, ctx, size) {
|
|
const caretPosition = this.getCaretPosition(tooltipPoint, size);
|
|
|
|
ctx.lineTo(caretPosition.x1, caretPosition.y1);
|
|
ctx.lineTo(caretPosition.x2, caretPosition.y2);
|
|
ctx.lineTo(caretPosition.x3, caretPosition.y3);
|
|
}
|
|
|
|
getCaretPosition(tooltipPoint, size) {
|
|
const {xAlign, yAlign, options} = this;
|
|
const {cornerRadius, caretSize} = options;
|
|
const {x: ptX, y: ptY} = tooltipPoint;
|
|
const {width, height} = size;
|
|
let x1, x2, x3, y1, y2, y3;
|
|
|
|
if (yAlign === 'center') {
|
|
y2 = ptY + (height / 2);
|
|
|
|
if (xAlign === 'left') {
|
|
x1 = ptX;
|
|
x2 = x1 - caretSize;
|
|
|
|
// Left draws bottom -> top, this y1 is on the bottom
|
|
y1 = y2 + caretSize;
|
|
y3 = y2 - caretSize;
|
|
} else {
|
|
x1 = ptX + width;
|
|
x2 = x1 + caretSize;
|
|
|
|
// Right draws top -> bottom, thus y1 is on the top
|
|
y1 = y2 - caretSize;
|
|
y3 = y2 + caretSize;
|
|
}
|
|
|
|
x3 = x1;
|
|
} else {
|
|
if (xAlign === 'left') {
|
|
x2 = ptX + cornerRadius + (caretSize);
|
|
} else if (xAlign === 'right') {
|
|
x2 = ptX + width - cornerRadius - caretSize;
|
|
} else {
|
|
x2 = this.caretX;
|
|
}
|
|
|
|
if (yAlign === 'top') {
|
|
y1 = ptY;
|
|
y2 = y1 - caretSize;
|
|
|
|
// Top draws left -> right, thus x1 is on the left
|
|
x1 = x2 - caretSize;
|
|
x3 = x2 + caretSize;
|
|
} else {
|
|
y1 = ptY + height;
|
|
y2 = y1 + caretSize;
|
|
|
|
// Bottom draws right -> left, thus x1 is on the right
|
|
x1 = x2 + caretSize;
|
|
x3 = x2 - caretSize;
|
|
}
|
|
y3 = y1;
|
|
}
|
|
return {x1, x2, x3, y1, y2, y3};
|
|
}
|
|
|
|
drawTitle(pt, ctx) {
|
|
const me = this;
|
|
const options = me.options;
|
|
const title = me.title;
|
|
const length = title.length;
|
|
let titleFont, titleSpacing, i;
|
|
|
|
if (length) {
|
|
const rtlHelper = getRtlAdapter(options.rtl, me.x, me.width);
|
|
|
|
pt.x = getAlignedX(me, options.titleAlign);
|
|
|
|
ctx.textAlign = rtlHelper.textAlign(options.titleAlign);
|
|
ctx.textBaseline = 'middle';
|
|
|
|
titleFont = options.titleFont;
|
|
titleSpacing = options.titleSpacing;
|
|
|
|
ctx.fillStyle = options.titleFont.color;
|
|
ctx.font = titleFont.string;
|
|
|
|
for (i = 0; i < length; ++i) {
|
|
ctx.fillText(title[i], rtlHelper.x(pt.x), pt.y + titleFont.size / 2);
|
|
pt.y += titleFont.size + titleSpacing; // Line Height and spacing
|
|
|
|
if (i + 1 === length) {
|
|
pt.y += options.titleMarginBottom - titleSpacing; // If Last, add margin, remove spacing
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_drawColorBox(ctx, pt, i, rtlHelper) {
|
|
const me = this;
|
|
const options = me.options;
|
|
const labelColors = me.labelColors[i];
|
|
const labelPointStyle = me.labelPointStyles[i];
|
|
const {boxHeight, boxWidth, bodyFont} = options;
|
|
const colorX = getAlignedX(me, 'left');
|
|
const rtlColorX = rtlHelper.x(colorX);
|
|
const yOffSet = boxHeight < bodyFont.size ? (bodyFont.size - boxHeight) / 2 : 0;
|
|
const colorY = pt.y + yOffSet;
|
|
|
|
if (options.usePointStyle) {
|
|
const drawOptions = {
|
|
radius: Math.min(boxWidth, boxHeight) / 2, // fit the circle in the box
|
|
pointStyle: labelPointStyle.pointStyle,
|
|
rotation: labelPointStyle.rotation,
|
|
borderWidth: 1
|
|
};
|
|
// Recalculate x and y for drawPoint() because its expecting
|
|
// x and y to be center of figure (instead of top left)
|
|
const centerX = rtlHelper.leftForLtr(rtlColorX, boxWidth) + boxWidth / 2;
|
|
const centerY = colorY + boxHeight / 2;
|
|
|
|
// Fill the point with white so that colours merge nicely if the opacity is < 1
|
|
ctx.strokeStyle = options.multiKeyBackground;
|
|
ctx.fillStyle = options.multiKeyBackground;
|
|
drawPoint(ctx, drawOptions, centerX, centerY);
|
|
|
|
// Draw the point
|
|
ctx.strokeStyle = labelColors.borderColor;
|
|
ctx.fillStyle = labelColors.backgroundColor;
|
|
drawPoint(ctx, drawOptions, centerX, centerY);
|
|
} else {
|
|
// Fill a white rect so that colours merge nicely if the opacity is < 1
|
|
ctx.fillStyle = options.multiKeyBackground;
|
|
ctx.fillRect(rtlHelper.leftForLtr(rtlColorX, boxWidth), colorY, boxWidth, boxHeight);
|
|
|
|
// Border
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = labelColors.borderColor;
|
|
ctx.strokeRect(rtlHelper.leftForLtr(rtlColorX, boxWidth), colorY, boxWidth, boxHeight);
|
|
|
|
// Inner square
|
|
ctx.fillStyle = labelColors.backgroundColor;
|
|
ctx.fillRect(rtlHelper.leftForLtr(rtlHelper.xPlus(rtlColorX, 1), boxWidth - 2), colorY + 1, boxWidth - 2, boxHeight - 2);
|
|
}
|
|
|
|
// restore fillStyle
|
|
ctx.fillStyle = me.labelTextColors[i];
|
|
}
|
|
|
|
drawBody(pt, ctx) {
|
|
const me = this;
|
|
const {body, options} = me;
|
|
const {bodyFont, bodySpacing, bodyAlign, displayColors, boxHeight, boxWidth} = options;
|
|
let bodyLineHeight = bodyFont.size;
|
|
let xLinePadding = 0;
|
|
|
|
const rtlHelper = getRtlAdapter(options.rtl, me.x, me.width);
|
|
|
|
const fillLineOfText = function(line) {
|
|
ctx.fillText(line, rtlHelper.x(pt.x + xLinePadding), pt.y + bodyLineHeight / 2);
|
|
pt.y += bodyLineHeight + bodySpacing;
|
|
};
|
|
|
|
const bodyAlignForCalculation = rtlHelper.textAlign(bodyAlign);
|
|
let bodyItem, textColor, lines, i, j, ilen, jlen;
|
|
|
|
ctx.textAlign = bodyAlign;
|
|
ctx.textBaseline = 'middle';
|
|
ctx.font = bodyFont.string;
|
|
|
|
pt.x = getAlignedX(me, bodyAlignForCalculation);
|
|
|
|
// Before body lines
|
|
ctx.fillStyle = bodyFont.color;
|
|
each(me.beforeBody, fillLineOfText);
|
|
|
|
xLinePadding = displayColors && bodyAlignForCalculation !== 'right'
|
|
? bodyAlign === 'center' ? (boxWidth / 2 + 1) : (boxWidth + 2)
|
|
: 0;
|
|
|
|
// Draw body lines now
|
|
for (i = 0, ilen = body.length; i < ilen; ++i) {
|
|
bodyItem = body[i];
|
|
textColor = me.labelTextColors[i];
|
|
|
|
ctx.fillStyle = textColor;
|
|
each(bodyItem.before, fillLineOfText);
|
|
|
|
lines = bodyItem.lines;
|
|
// Draw Legend-like boxes if needed
|
|
if (displayColors && lines.length) {
|
|
me._drawColorBox(ctx, pt, i, rtlHelper);
|
|
bodyLineHeight = Math.max(bodyFont.size, boxHeight);
|
|
}
|
|
|
|
for (j = 0, jlen = lines.length; j < jlen; ++j) {
|
|
fillLineOfText(lines[j]);
|
|
// Reset for any lines that don't include colorbox
|
|
bodyLineHeight = bodyFont.size;
|
|
}
|
|
|
|
each(bodyItem.after, fillLineOfText);
|
|
}
|
|
|
|
// Reset back to 0 for after body
|
|
xLinePadding = 0;
|
|
bodyLineHeight = bodyFont.size;
|
|
|
|
// After body lines
|
|
each(me.afterBody, fillLineOfText);
|
|
pt.y -= bodySpacing; // Remove last body spacing
|
|
}
|
|
|
|
drawFooter(pt, ctx) {
|
|
const me = this;
|
|
const options = me.options;
|
|
const footer = me.footer;
|
|
const length = footer.length;
|
|
let footerFont, i;
|
|
|
|
if (length) {
|
|
const rtlHelper = getRtlAdapter(options.rtl, me.x, me.width);
|
|
|
|
pt.x = getAlignedX(me, options.footerAlign);
|
|
pt.y += options.footerMarginTop;
|
|
|
|
ctx.textAlign = rtlHelper.textAlign(options.footerAlign);
|
|
ctx.textBaseline = 'middle';
|
|
|
|
footerFont = options.footerFont;
|
|
|
|
ctx.fillStyle = options.footerFont.color;
|
|
ctx.font = footerFont.string;
|
|
|
|
for (i = 0; i < length; ++i) {
|
|
ctx.fillText(footer[i], rtlHelper.x(pt.x), pt.y + footerFont.size / 2);
|
|
pt.y += footerFont.size + options.footerSpacing;
|
|
}
|
|
}
|
|
}
|
|
|
|
drawBackground(pt, ctx, tooltipSize) {
|
|
const {xAlign, yAlign, options} = this;
|
|
const {x, y} = pt;
|
|
const {width, height} = tooltipSize;
|
|
const radius = options.cornerRadius;
|
|
|
|
ctx.fillStyle = options.backgroundColor;
|
|
ctx.strokeStyle = options.borderColor;
|
|
ctx.lineWidth = options.borderWidth;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + radius, y);
|
|
if (yAlign === 'top') {
|
|
this.drawCaret(pt, ctx, tooltipSize);
|
|
}
|
|
ctx.lineTo(x + width - radius, y);
|
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
if (yAlign === 'center' && xAlign === 'right') {
|
|
this.drawCaret(pt, ctx, tooltipSize);
|
|
}
|
|
ctx.lineTo(x + width, y + height - radius);
|
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
if (yAlign === 'bottom') {
|
|
this.drawCaret(pt, ctx, tooltipSize);
|
|
}
|
|
ctx.lineTo(x + radius, y + height);
|
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
if (yAlign === 'center' && xAlign === 'left') {
|
|
this.drawCaret(pt, ctx, tooltipSize);
|
|
}
|
|
ctx.lineTo(x, y + radius);
|
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
ctx.closePath();
|
|
|
|
ctx.fill();
|
|
|
|
if (options.borderWidth > 0) {
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update x/y animation targets when _active elements are animating too
|
|
* @private
|
|
*/
|
|
_updateAnimationTarget() {
|
|
const me = this;
|
|
const chart = me._chart;
|
|
const options = me.options;
|
|
const anims = me.$animations;
|
|
const animX = anims && anims.x;
|
|
const animY = anims && anims.y;
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
draw(ctx) {
|
|
const me = this;
|
|
const options = me.options;
|
|
let opacity = me.opacity;
|
|
|
|
if (!opacity) {
|
|
return;
|
|
}
|
|
|
|
me._updateAnimationTarget();
|
|
|
|
const tooltipSize = {
|
|
width: me.width,
|
|
height: me.height
|
|
};
|
|
const pt = {
|
|
x: me.x,
|
|
y: me.y
|
|
};
|
|
|
|
// IE11/Edge does not like very small opacities, so snap to 0
|
|
opacity = Math.abs(opacity) < 1e-3 ? 0 : opacity;
|
|
|
|
// Truthy/falsey value for empty tooltip
|
|
const hasTooltipContent = me.title.length || me.beforeBody.length || me.body.length || me.afterBody.length || me.footer.length;
|
|
|
|
if (options.enabled && hasTooltipContent) {
|
|
ctx.save();
|
|
ctx.globalAlpha = opacity;
|
|
|
|
// Draw Background
|
|
me.drawBackground(pt, ctx, tooltipSize);
|
|
|
|
overrideTextDirection(ctx, options.textDirection);
|
|
|
|
pt.y += options.yPadding;
|
|
|
|
// Titles
|
|
me.drawTitle(pt, ctx);
|
|
|
|
// Body
|
|
me.drawBody(pt, ctx);
|
|
|
|
// Footer
|
|
me.drawFooter(pt, ctx);
|
|
|
|
restoreTextDirection(ctx, options.textDirection);
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get active elements in the tooltip
|
|
* @returns {Array} Array of elements that are active in the tooltip
|
|
*/
|
|
getActiveElements() {
|
|
return this._active || [];
|
|
}
|
|
|
|
/**
|
|
* Set active elements in the tooltip
|
|
* @param {array} activeElements Array of active datasetIndex/index pairs.
|
|
* @param {object} eventPosition Synthetic event position used in positioning
|
|
*/
|
|
setActiveElements(activeElements, eventPosition) {
|
|
const me = this;
|
|
const lastActive = me._active;
|
|
const active = activeElements.map(({datasetIndex, index}) => {
|
|
const meta = me._chart.getDatasetMeta(datasetIndex);
|
|
|
|
if (!meta) {
|
|
throw new Error('Cannot find a dataset at index ' + datasetIndex);
|
|
}
|
|
|
|
return {
|
|
datasetIndex,
|
|
element: meta.data[index],
|
|
index,
|
|
};
|
|
});
|
|
const changed = !_elementsEqual(lastActive, active);
|
|
const positionChanged = me._positionChanged(active, eventPosition);
|
|
|
|
if (changed || positionChanged) {
|
|
me._active = active;
|
|
me._eventPosition = eventPosition;
|
|
me.update(true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, replay) {
|
|
const me = this;
|
|
const options = me.options;
|
|
const lastActive = me._active || [];
|
|
let changed = false;
|
|
let active = [];
|
|
|
|
// Find Active Elements for tooltips
|
|
if (e.type !== 'mouseout') {
|
|
active = me._chart.getElementsAtEventForMode(e, options.mode, options, replay);
|
|
if (options.reverse) {
|
|
active.reverse();
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// the items are the same as before.
|
|
const positionChanged = me._positionChanged(active, e);
|
|
|
|
// Remember Last Actives
|
|
changed = replay || !_elementsEqual(active, lastActive) || positionChanged;
|
|
|
|
// Only handle target event on tooltip change
|
|
if (changed) {
|
|
me._active = active;
|
|
|
|
if (options.enabled || options.custom) {
|
|
me._eventPosition = {
|
|
x: e.x,
|
|
y: e.y
|
|
};
|
|
|
|
me.update(true);
|
|
}
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
/**
|
|
* Determine if the active elements + event combination changes the
|
|
* tooltip position
|
|
* @param {array} active - Active elements
|
|
* @param {IEvent} e - Event that triggered the position change
|
|
* @returns {boolean} True if the position has changed
|
|
*/
|
|
_positionChanged(active, e) {
|
|
const me = this;
|
|
const position = positioners[me.options.position].call(me, active, e);
|
|
return me.caretX !== position.x || me.caretY !== position.y;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @namespace Chart.Tooltip.positioners
|
|
*/
|
|
Tooltip.positioners = positioners;
|
|
|
|
export default {
|
|
id: 'tooltip',
|
|
_element: Tooltip,
|
|
positioners,
|
|
|
|
afterInit(chart) {
|
|
const tooltipOpts = chart.options.tooltips;
|
|
|
|
if (tooltipOpts) {
|
|
chart.tooltip = new Tooltip({_chart: chart});
|
|
}
|
|
},
|
|
|
|
beforeUpdate(chart) {
|
|
if (chart.tooltip) {
|
|
chart.tooltip.initialize();
|
|
}
|
|
},
|
|
|
|
reset(chart) {
|
|
if (chart.tooltip) {
|
|
chart.tooltip.initialize();
|
|
}
|
|
},
|
|
|
|
afterDraw(chart) {
|
|
const tooltip = chart.tooltip;
|
|
|
|
const args = {
|
|
tooltip
|
|
};
|
|
|
|
if (chart._plugins.notify(chart, 'beforeTooltipDraw', [args]) === false) {
|
|
return;
|
|
}
|
|
|
|
if (tooltip) {
|
|
tooltip.draw(chart.ctx);
|
|
}
|
|
|
|
chart._plugins.notify(chart, 'afterTooltipDraw', [args]);
|
|
},
|
|
|
|
afterEvent(chart, e, replay) {
|
|
if (chart.tooltip) {
|
|
// If the event is replayed from `update`, we should evaluate with the final positions.
|
|
const useFinalPosition = replay;
|
|
chart.tooltip.handleEvent(e, useFinalPosition);
|
|
}
|
|
},
|
|
|
|
defaults: {
|
|
enabled: true,
|
|
custom: null,
|
|
position: 'average',
|
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
titleFont: {
|
|
style: 'bold',
|
|
color: '#fff',
|
|
},
|
|
titleSpacing: 2,
|
|
titleMarginBottom: 6,
|
|
titleAlign: 'left',
|
|
bodySpacing: 2,
|
|
bodyFont: {
|
|
color: '#fff',
|
|
},
|
|
bodyAlign: 'left',
|
|
footerSpacing: 2,
|
|
footerMarginTop: 6,
|
|
footerFont: {
|
|
color: '#fff',
|
|
style: 'bold',
|
|
},
|
|
footerAlign: 'left',
|
|
yPadding: 6,
|
|
xPadding: 6,
|
|
caretPadding: 2,
|
|
caretSize: 5,
|
|
cornerRadius: 6,
|
|
multiKeyBackground: '#fff',
|
|
displayColors: true,
|
|
borderColor: 'rgba(0,0,0,0)',
|
|
borderWidth: 0,
|
|
animation: {
|
|
duration: 400,
|
|
easing: 'easeOutQuart',
|
|
numbers: {
|
|
type: 'number',
|
|
properties: ['x', 'y', 'width', 'height', 'caretX', 'caretY'],
|
|
},
|
|
opacity: {
|
|
easing: 'linear',
|
|
duration: 200
|
|
}
|
|
},
|
|
callbacks: {
|
|
// Args are: (tooltipItems, data)
|
|
beforeTitle: noop,
|
|
title(tooltipItems) {
|
|
if (tooltipItems.length > 0) {
|
|
const item = tooltipItems[0];
|
|
const labels = item.chart.data.labels;
|
|
const labelCount = labels ? labels.length : 0;
|
|
if (item.label) {
|
|
return item.label;
|
|
} else if (labelCount > 0 && item.dataIndex < labelCount) {
|
|
return labels[item.dataIndex];
|
|
}
|
|
}
|
|
|
|
return '';
|
|
},
|
|
afterTitle: noop,
|
|
|
|
// Args are: (tooltipItems, data)
|
|
beforeBody: noop,
|
|
|
|
// Args are: (tooltipItem, data)
|
|
beforeLabel: noop,
|
|
label(tooltipItem) {
|
|
let label = tooltipItem.dataset.label || '';
|
|
|
|
if (label) {
|
|
label += ': ';
|
|
}
|
|
const value = tooltipItem.formattedValue;
|
|
if (!isNullOrUndef(value)) {
|
|
label += value;
|
|
}
|
|
return label;
|
|
},
|
|
labelColor(tooltipItem) {
|
|
const meta = tooltipItem.chart.getDatasetMeta(tooltipItem.datasetIndex);
|
|
const options = meta.controller.getStyle(tooltipItem.dataIndex);
|
|
return {
|
|
borderColor: options.borderColor,
|
|
backgroundColor: options.backgroundColor
|
|
};
|
|
},
|
|
labelTextColor() {
|
|
return this.options.bodyFont.color;
|
|
},
|
|
labelPointStyle(tooltipItem) {
|
|
const meta = tooltipItem.chart.getDatasetMeta(tooltipItem.datasetIndex);
|
|
const options = meta.controller.getStyle(tooltipItem.dataIndex);
|
|
return {
|
|
pointStyle: options.pointStyle,
|
|
rotation: options.rotation,
|
|
};
|
|
},
|
|
afterLabel: noop,
|
|
|
|
// Args are: (tooltipItems, data)
|
|
afterBody: noop,
|
|
|
|
// Args are: (tooltipItems, data)
|
|
beforeFooter: noop,
|
|
footer: noop,
|
|
afterFooter: noop
|
|
}
|
|
},
|
|
};
|