3040 lines
90 KiB
JavaScript
3040 lines
90 KiB
JavaScript
/*!
|
|
* chartjs-plugin-annotation v3.1.0
|
|
* https://www.chartjs.org/chartjs-plugin-annotation/index
|
|
* (c) 2024 chartjs-plugin-annotation Contributors
|
|
* Released under the MIT License
|
|
*/
|
|
(function (global, factory) {
|
|
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('chart.js'), require('chart.js/helpers')) :
|
|
typeof define === 'function' && define.amd ? define(['chart.js', 'chart.js/helpers'], factory) :
|
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global["chartjs-plugin-annotation"] = factory(global.Chart, global.Chart.helpers));
|
|
})(this, (function (chart_js, helpers) { 'use strict';
|
|
|
|
/**
|
|
* @typedef { import("chart.js").ChartEvent } ChartEvent
|
|
* @typedef { import('../../types/element').AnnotationElement } AnnotationElement
|
|
*/
|
|
|
|
const interaction = {
|
|
modes: {
|
|
/**
|
|
* Point mode returns all elements that hit test based on the event position
|
|
* @param {AnnotationElement[]} visibleElements - annotation elements which are visible
|
|
* @param {ChartEvent} event - the event we are find things at
|
|
* @return {AnnotationElement[]} - elements that are found
|
|
*/
|
|
point(visibleElements, event) {
|
|
return filterElements(visibleElements, event, {intersect: true});
|
|
},
|
|
|
|
/**
|
|
* Nearest mode returns the element closest to the event position
|
|
* @param {AnnotationElement[]} visibleElements - annotation elements which are visible
|
|
* @param {ChartEvent} event - the event we are find things at
|
|
* @param {Object} options - interaction options to use
|
|
* @return {AnnotationElement[]} - elements that are found (only 1 element)
|
|
*/
|
|
nearest(visibleElements, event, options) {
|
|
return getNearestItem(visibleElements, event, options);
|
|
},
|
|
/**
|
|
* x mode returns the elements that hit-test at the current x coordinate
|
|
* @param {AnnotationElement[]} visibleElements - annotation elements which are visible
|
|
* @param {ChartEvent} event - the event we are find things at
|
|
* @param {Object} options - interaction options to use
|
|
* @return {AnnotationElement[]} - elements that are found
|
|
*/
|
|
x(visibleElements, event, options) {
|
|
return filterElements(visibleElements, event, {intersect: options.intersect, axis: 'x'});
|
|
},
|
|
|
|
/**
|
|
* y mode returns the elements that hit-test at the current y coordinate
|
|
* @param {AnnotationElement[]} visibleElements - annotation elements which are visible
|
|
* @param {ChartEvent} event - the event we are find things at
|
|
* @param {Object} options - interaction options to use
|
|
* @return {AnnotationElement[]} - elements that are found
|
|
*/
|
|
y(visibleElements, event, options) {
|
|
return filterElements(visibleElements, event, {intersect: options.intersect, axis: 'y'});
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Returns all elements that hit test based on the event position
|
|
* @param {AnnotationElement[]} visibleElements - annotation elements which are visible
|
|
* @param {ChartEvent} event - the event we are find things at
|
|
* @param {Object} options - interaction options to use
|
|
* @return {AnnotationElement[]} - elements that are found
|
|
*/
|
|
function getElements(visibleElements, event, options) {
|
|
const mode = interaction.modes[options.mode] || interaction.modes.nearest;
|
|
return mode(visibleElements, event, options);
|
|
}
|
|
|
|
function inRangeByAxis(element, event, axis) {
|
|
if (axis !== 'x' && axis !== 'y') {
|
|
return element.inRange(event.x, event.y, 'x', true) || element.inRange(event.x, event.y, 'y', true);
|
|
}
|
|
return element.inRange(event.x, event.y, axis, true);
|
|
}
|
|
|
|
function getPointByAxis(event, center, axis) {
|
|
if (axis === 'x') {
|
|
return {x: event.x, y: center.y};
|
|
} else if (axis === 'y') {
|
|
return {x: center.x, y: event.y};
|
|
}
|
|
return center;
|
|
}
|
|
|
|
function filterElements(visibleElements, event, options) {
|
|
return visibleElements.filter((element) => options.intersect ? element.inRange(event.x, event.y) : inRangeByAxis(element, event, options.axis));
|
|
}
|
|
|
|
function getNearestItem(visibleElements, event, options) {
|
|
let minDistance = Number.POSITIVE_INFINITY;
|
|
|
|
return filterElements(visibleElements, event, options)
|
|
.reduce((nearestItems, element) => {
|
|
const center = element.getCenterPoint();
|
|
const evenPoint = getPointByAxis(event, center, options.axis);
|
|
const distance = helpers.distanceBetweenPoints(event, evenPoint);
|
|
if (distance < minDistance) {
|
|
nearestItems = [element];
|
|
minDistance = distance;
|
|
} else if (distance === minDistance) {
|
|
// Can have multiple items at the same distance in which case we sort by size
|
|
nearestItems.push(element);
|
|
}
|
|
|
|
return nearestItems;
|
|
}, [])
|
|
.sort((a, b) => a._index - b._index)
|
|
.slice(0, 1); // return only the top item;
|
|
}
|
|
|
|
/**
|
|
* @typedef {import('chart.js').Point} Point
|
|
*/
|
|
|
|
/**
|
|
* Rotate a `point` relative to `center` point by `angle`
|
|
* @param {Point} point - the point to rotate
|
|
* @param {Point} center - center point for rotation
|
|
* @param {number} angle - angle for rotation, in radians
|
|
* @returns {Point} rotated point
|
|
*/
|
|
function rotated(point, center, angle) {
|
|
const cos = Math.cos(angle);
|
|
const sin = Math.sin(angle);
|
|
const cx = center.x;
|
|
const cy = center.y;
|
|
|
|
return {
|
|
x: cx + cos * (point.x - cx) - sin * (point.y - cy),
|
|
y: cy + sin * (point.x - cx) + cos * (point.y - cy)
|
|
};
|
|
}
|
|
|
|
const isOlderPart = (act, req) => req > act || (act.length > req.length && act.slice(0, req.length) === req);
|
|
|
|
/**
|
|
* @typedef { import('chart.js').Point } Point
|
|
* @typedef { import('chart.js').InteractionAxis } InteractionAxis
|
|
* @typedef { import('../../types/element').AnnotationElement } AnnotationElement
|
|
*/
|
|
|
|
const EPSILON = 0.001;
|
|
const clamp = (x, from, to) => Math.min(to, Math.max(from, x));
|
|
|
|
/**
|
|
* @param {{value: number, start: number, end: number}} limit
|
|
* @param {number} hitSize
|
|
* @returns {boolean}
|
|
*/
|
|
const inLimit = (limit, hitSize) => limit.value >= limit.start - hitSize && limit.value <= limit.end + hitSize;
|
|
|
|
/**
|
|
* @param {Object} obj
|
|
* @param {number} from
|
|
* @param {number} to
|
|
* @returns {Object}
|
|
*/
|
|
function clampAll(obj, from, to) {
|
|
for (const key of Object.keys(obj)) {
|
|
obj[key] = clamp(obj[key], from, to);
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
/**
|
|
* @param {Point} point
|
|
* @param {Point} center
|
|
* @param {number} radius
|
|
* @param {number} hitSize
|
|
* @returns {boolean}
|
|
*/
|
|
function inPointRange(point, center, radius, hitSize) {
|
|
if (!point || !center || radius <= 0) {
|
|
return false;
|
|
}
|
|
return (Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2)) <= Math.pow(radius + hitSize, 2);
|
|
}
|
|
|
|
/**
|
|
* @param {Point} point
|
|
* @param {{x: number, y: number, x2: number, y2: number}} rect
|
|
* @param {InteractionAxis} axis
|
|
* @param {{borderWidth: number, hitTolerance: number}} hitsize
|
|
* @returns {boolean}
|
|
*/
|
|
function inBoxRange(point, {x, y, x2, y2}, axis, {borderWidth, hitTolerance}) {
|
|
const hitSize = (borderWidth + hitTolerance) / 2;
|
|
const inRangeX = point.x >= x - hitSize - EPSILON && point.x <= x2 + hitSize + EPSILON;
|
|
const inRangeY = point.y >= y - hitSize - EPSILON && point.y <= y2 + hitSize + EPSILON;
|
|
if (axis === 'x') {
|
|
return inRangeX;
|
|
} else if (axis === 'y') {
|
|
return inRangeY;
|
|
}
|
|
return inRangeX && inRangeY;
|
|
}
|
|
|
|
/**
|
|
* @param {Point} point
|
|
* @param {rect: {x: number, y: number, x2: number, y2: number}, center: {x: number, y: number}} element
|
|
* @param {InteractionAxis} axis
|
|
* @param {{rotation: number, borderWidth: number, hitTolerance: number}}
|
|
* @returns {boolean}
|
|
*/
|
|
function inLabelRange(point, {rect, center}, axis, {rotation, borderWidth, hitTolerance}) {
|
|
const rotPoint = rotated(point, center, helpers.toRadians(-rotation));
|
|
return inBoxRange(rotPoint, rect, axis, {borderWidth, hitTolerance});
|
|
}
|
|
|
|
/**
|
|
* @param {AnnotationElement} element
|
|
* @param {boolean} useFinalPosition
|
|
* @returns {Point}
|
|
*/
|
|
function getElementCenterPoint(element, useFinalPosition) {
|
|
const {centerX, centerY} = element.getProps(['centerX', 'centerY'], useFinalPosition);
|
|
return {x: centerX, y: centerY};
|
|
}
|
|
|
|
/**
|
|
* @param {string} pkg
|
|
* @param {string} min
|
|
* @param {string} ver
|
|
* @param {boolean} [strict=true]
|
|
* @returns {boolean}
|
|
*/
|
|
function requireVersion(pkg, min, ver, strict = true) {
|
|
const parts = ver.split('.');
|
|
let i = 0;
|
|
for (const req of min.split('.')) {
|
|
const act = parts[i++];
|
|
if (parseInt(req, 10) < parseInt(act, 10)) {
|
|
break;
|
|
}
|
|
if (isOlderPart(act, req)) {
|
|
if (strict) {
|
|
throw new Error(`${pkg} v${ver} is not supported. v${min} or newer is required.`);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const isPercentString = (s) => typeof s === 'string' && s.endsWith('%');
|
|
const toPercent = (s) => parseFloat(s) / 100;
|
|
const toPositivePercent = (s) => clamp(toPercent(s), 0, 1);
|
|
|
|
const boxAppering = (x, y) => ({x, y, x2: x, y2: y, width: 0, height: 0});
|
|
const defaultInitAnimation = {
|
|
box: (properties) => boxAppering(properties.centerX, properties.centerY),
|
|
doughnutLabel: (properties) => boxAppering(properties.centerX, properties.centerY),
|
|
ellipse: (properties) => ({centerX: properties.centerX, centerY: properties.centerX, radius: 0, width: 0, height: 0}),
|
|
label: (properties) => boxAppering(properties.centerX, properties.centerY),
|
|
line: (properties) => boxAppering(properties.x, properties.y),
|
|
point: (properties) => ({centerX: properties.centerX, centerY: properties.centerY, radius: 0, width: 0, height: 0}),
|
|
polygon: (properties) => boxAppering(properties.centerX, properties.centerY)
|
|
};
|
|
|
|
/**
|
|
* @typedef { import('chart.js').FontSpec } FontSpec
|
|
* @typedef { import('chart.js').Point } Point
|
|
* @typedef { import('chart.js').Padding } Padding
|
|
* @typedef { import('../../types/element').AnnotationBoxModel } AnnotationBoxModel
|
|
* @typedef { import('../../types/element').AnnotationElement } AnnotationElement
|
|
* @typedef { import('../../types/options').AnnotationPointCoordinates } AnnotationPointCoordinates
|
|
* @typedef { import('../../types/label').CoreLabelOptions } CoreLabelOptions
|
|
* @typedef { import('../../types/label').LabelPositionObject } LabelPositionObject
|
|
*/
|
|
|
|
/**
|
|
* @param {number} size
|
|
* @param {number|string} position
|
|
* @returns {number}
|
|
*/
|
|
function getRelativePosition(size, position) {
|
|
if (position === 'start') {
|
|
return 0;
|
|
}
|
|
if (position === 'end') {
|
|
return size;
|
|
}
|
|
if (isPercentString(position)) {
|
|
return toPositivePercent(position) * size;
|
|
}
|
|
return size / 2;
|
|
}
|
|
|
|
/**
|
|
* @param {number} size
|
|
* @param {number|string} value
|
|
* @param {boolean} [positivePercent=true]
|
|
* @returns {number}
|
|
*/
|
|
function getSize(size, value, positivePercent = true) {
|
|
if (typeof value === 'number') {
|
|
return value;
|
|
} else if (isPercentString(value)) {
|
|
return (positivePercent ? toPositivePercent(value) : toPercent(value)) * size;
|
|
}
|
|
return size;
|
|
}
|
|
|
|
/**
|
|
* @param {{x: number, width: number}} size
|
|
* @param {CoreLabelOptions} options
|
|
* @returns {number}
|
|
*/
|
|
function calculateTextAlignment(size, options) {
|
|
const {x, width} = size;
|
|
const textAlign = options.textAlign;
|
|
if (textAlign === 'center') {
|
|
return x + width / 2;
|
|
} else if (textAlign === 'end' || textAlign === 'right') {
|
|
return x + width;
|
|
}
|
|
return x;
|
|
}
|
|
|
|
/**
|
|
* @param {Point} point
|
|
* @param {{height: number, width: number}} labelSize
|
|
* @param {{borderWidth: number, position: {LabelPositionObject|string}, xAdjust: number, yAdjust: number}} options
|
|
* @param {Padding|undefined} padding
|
|
* @returns {{x: number, y: number, x2: number, y2: number, height: number, width: number, centerX: number, centerY: number}}
|
|
*/
|
|
function measureLabelRectangle(point, labelSize, {borderWidth, position, xAdjust, yAdjust}, padding) {
|
|
const hasPadding = helpers.isObject(padding);
|
|
const width = labelSize.width + (hasPadding ? padding.width : 0) + borderWidth;
|
|
const height = labelSize.height + (hasPadding ? padding.height : 0) + borderWidth;
|
|
const positionObj = toPosition(position);
|
|
const x = calculateLabelPosition$1(point.x, width, xAdjust, positionObj.x);
|
|
const y = calculateLabelPosition$1(point.y, height, yAdjust, positionObj.y);
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
x2: x + width,
|
|
y2: y + height,
|
|
width,
|
|
height,
|
|
centerX: x + width / 2,
|
|
centerY: y + height / 2
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {LabelPositionObject|string} value
|
|
* @param {string|number} defaultValue
|
|
* @returns {LabelPositionObject}
|
|
*/
|
|
function toPosition(value, defaultValue = 'center') {
|
|
if (helpers.isObject(value)) {
|
|
return {
|
|
x: helpers.valueOrDefault(value.x, defaultValue),
|
|
y: helpers.valueOrDefault(value.y, defaultValue),
|
|
};
|
|
}
|
|
value = helpers.valueOrDefault(value, defaultValue);
|
|
return {
|
|
x: value,
|
|
y: value
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {CoreLabelOptions} options
|
|
* @param {number} fitRatio
|
|
* @returns {boolean}
|
|
*/
|
|
const shouldFit = (options, fitRatio) => options && options.autoFit && fitRatio < 1;
|
|
|
|
/**
|
|
* @param {CoreLabelOptions} options
|
|
* @param {number} fitRatio
|
|
* @returns {FontSpec[]}
|
|
*/
|
|
function toFonts(options, fitRatio) {
|
|
const optFont = options.font;
|
|
const fonts = helpers.isArray(optFont) ? optFont : [optFont];
|
|
if (shouldFit(options, fitRatio)) {
|
|
return fonts.map(function(f) {
|
|
const font = helpers.toFont(f);
|
|
font.size = Math.floor(f.size * fitRatio);
|
|
font.lineHeight = f.lineHeight;
|
|
return helpers.toFont(font);
|
|
});
|
|
}
|
|
return fonts.map(f => helpers.toFont(f));
|
|
}
|
|
|
|
/**
|
|
* @param {AnnotationPointCoordinates} options
|
|
* @returns {boolean}
|
|
*/
|
|
function isBoundToPoint(options) {
|
|
return options && (helpers.defined(options.xValue) || helpers.defined(options.yValue));
|
|
}
|
|
|
|
function calculateLabelPosition$1(start, size, adjust = 0, position) {
|
|
return start - getRelativePosition(size, position) + adjust;
|
|
}
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {AnnotationBoxModel} properties
|
|
* @param {CoreAnnotationOptions} options
|
|
* @returns {AnnotationElement}
|
|
*/
|
|
function initAnimationProperties(chart, properties, options) {
|
|
const initAnim = options.init;
|
|
if (!initAnim) {
|
|
return;
|
|
} else if (initAnim === true) {
|
|
return applyDefault(properties, options);
|
|
}
|
|
return execCallback(chart, properties, options);
|
|
}
|
|
|
|
/**
|
|
* @param {Object} options
|
|
* @param {Array} hooks
|
|
* @param {Object} hooksContainer
|
|
* @returns {boolean}
|
|
*/
|
|
function loadHooks(options, hooks, hooksContainer) {
|
|
let activated = false;
|
|
hooks.forEach(hook => {
|
|
if (helpers.isFunction(options[hook])) {
|
|
activated = true;
|
|
hooksContainer[hook] = options[hook];
|
|
} else if (helpers.defined(hooksContainer[hook])) {
|
|
delete hooksContainer[hook];
|
|
}
|
|
});
|
|
return activated;
|
|
}
|
|
|
|
function applyDefault(properties, options) {
|
|
const type = options.type || 'line';
|
|
return defaultInitAnimation[type](properties);
|
|
}
|
|
|
|
function execCallback(chart, properties, options) {
|
|
const result = helpers.callback(options.init, [{chart, properties, options}]);
|
|
if (result === true) {
|
|
return applyDefault(properties, options);
|
|
} else if (helpers.isObject(result)) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const widthCache = new Map();
|
|
const notRadius = (radius) => isNaN(radius) || radius <= 0;
|
|
const fontsKey = (fonts) => fonts.reduce(function(prev, item) {
|
|
prev += item.string;
|
|
return prev;
|
|
}, '');
|
|
|
|
/**
|
|
* @typedef { import('chart.js').Point } Point
|
|
* @typedef { import('../../types/label').CoreLabelOptions } CoreLabelOptions
|
|
* @typedef { import('../../types/options').PointAnnotationOptions } PointAnnotationOptions
|
|
*/
|
|
|
|
/**
|
|
* Determine if content is an image or a canvas.
|
|
* @param {*} content
|
|
* @returns boolean|undefined
|
|
* @todo move this function to chart.js helpers
|
|
*/
|
|
function isImageOrCanvas(content) {
|
|
if (content && typeof content === 'object') {
|
|
const type = content.toString();
|
|
return (type === '[object HTMLImageElement]' || type === '[object HTMLCanvasElement]');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the translation on the canvas if the rotation must be applied.
|
|
* @param {CanvasRenderingContext2D} ctx - chart canvas context
|
|
* @param {Point} point - the point of translation
|
|
* @param {number} rotation - rotation (in degrees) to apply
|
|
*/
|
|
function translate(ctx, {x, y}, rotation) {
|
|
if (rotation) {
|
|
ctx.translate(x, y);
|
|
ctx.rotate(helpers.toRadians(rotation));
|
|
ctx.translate(-x, -y);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {Object} options
|
|
* @returns {boolean|undefined}
|
|
*/
|
|
function setBorderStyle(ctx, options) {
|
|
if (options && options.borderWidth) {
|
|
ctx.lineCap = options.borderCapStyle || 'butt';
|
|
ctx.setLineDash(options.borderDash);
|
|
ctx.lineDashOffset = options.borderDashOffset;
|
|
ctx.lineJoin = options.borderJoinStyle || 'miter';
|
|
ctx.lineWidth = options.borderWidth;
|
|
ctx.strokeStyle = options.borderColor;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {Object} options
|
|
*/
|
|
function setShadowStyle(ctx, options) {
|
|
ctx.shadowColor = options.backgroundShadowColor;
|
|
ctx.shadowBlur = options.shadowBlur;
|
|
ctx.shadowOffsetX = options.shadowOffsetX;
|
|
ctx.shadowOffsetY = options.shadowOffsetY;
|
|
}
|
|
|
|
/**
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {CoreLabelOptions} options
|
|
* @returns {{width: number, height: number}}
|
|
*/
|
|
function measureLabelSize(ctx, options) {
|
|
const content = options.content;
|
|
if (isImageOrCanvas(content)) {
|
|
const size = {
|
|
width: getSize(content.width, options.width),
|
|
height: getSize(content.height, options.height)
|
|
};
|
|
return size;
|
|
}
|
|
const fonts = toFonts(options);
|
|
const strokeWidth = options.textStrokeWidth;
|
|
const lines = helpers.isArray(content) ? content : [content];
|
|
const mapKey = lines.join() + fontsKey(fonts) + strokeWidth + (ctx._measureText ? '-spriting' : '');
|
|
if (!widthCache.has(mapKey)) {
|
|
widthCache.set(mapKey, calculateLabelSize(ctx, lines, fonts, strokeWidth));
|
|
}
|
|
return widthCache.get(mapKey);
|
|
}
|
|
|
|
/**
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {{x: number, y: number, width: number, height: number}} rect
|
|
* @param {Object} options
|
|
*/
|
|
function drawBox(ctx, rect, options) {
|
|
const {x, y, width, height} = rect;
|
|
ctx.save();
|
|
setShadowStyle(ctx, options);
|
|
const stroke = setBorderStyle(ctx, options);
|
|
ctx.fillStyle = options.backgroundColor;
|
|
ctx.beginPath();
|
|
helpers.addRoundedRectPath(ctx, {
|
|
x, y, w: width, h: height,
|
|
radius: clampAll(helpers.toTRBLCorners(options.borderRadius), 0, Math.min(width, height) / 2)
|
|
});
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
if (stroke) {
|
|
ctx.shadowColor = options.borderShadowColor;
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {{x: number, y: number, width: number, height: number}} rect
|
|
* @param {CoreLabelOptions} options
|
|
* @param {number} fitRatio
|
|
*/
|
|
function drawLabel(ctx, rect, options, fitRatio) {
|
|
const content = options.content;
|
|
if (isImageOrCanvas(content)) {
|
|
ctx.save();
|
|
ctx.globalAlpha = getOpacity(options.opacity, content.style.opacity);
|
|
ctx.drawImage(content, rect.x, rect.y, rect.width, rect.height);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
const labels = helpers.isArray(content) ? content : [content];
|
|
const fonts = toFonts(options, fitRatio);
|
|
const optColor = options.color;
|
|
const colors = helpers.isArray(optColor) ? optColor : [optColor];
|
|
const x = calculateTextAlignment(rect, options);
|
|
const y = rect.y + options.textStrokeWidth / 2;
|
|
ctx.save();
|
|
ctx.textBaseline = 'middle';
|
|
ctx.textAlign = options.textAlign;
|
|
if (setTextStrokeStyle(ctx, options)) {
|
|
applyLabelDecoration(ctx, {x, y}, labels, fonts);
|
|
}
|
|
applyLabelContent(ctx, {x, y}, labels, {fonts, colors});
|
|
ctx.restore();
|
|
}
|
|
|
|
function setTextStrokeStyle(ctx, options) {
|
|
if (options.textStrokeWidth > 0) {
|
|
// https://stackoverflow.com/questions/13627111/drawing-text-with-an-outer-stroke-with-html5s-canvas
|
|
ctx.lineJoin = 'round';
|
|
ctx.miterLimit = 2;
|
|
ctx.lineWidth = options.textStrokeWidth;
|
|
ctx.strokeStyle = options.textStrokeColor;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {{radius: number, options: PointAnnotationOptions}} element
|
|
* @param {number} x
|
|
* @param {number} y
|
|
*/
|
|
function drawPoint(ctx, element, x, y) {
|
|
const {radius, options} = element;
|
|
const style = options.pointStyle;
|
|
const rotation = options.rotation;
|
|
let rad = (rotation || 0) * helpers.RAD_PER_DEG;
|
|
|
|
if (isImageOrCanvas(style)) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rad);
|
|
ctx.drawImage(style, -style.width / 2, -style.height / 2, style.width, style.height);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
if (notRadius(radius)) {
|
|
return;
|
|
}
|
|
drawPointStyle(ctx, {x, y, radius, rotation, style, rad});
|
|
}
|
|
|
|
function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) {
|
|
let xOffset, yOffset, size, cornerRadius;
|
|
ctx.beginPath();
|
|
|
|
switch (style) {
|
|
// Default includes circle
|
|
default:
|
|
ctx.arc(x, y, radius, 0, helpers.TAU);
|
|
ctx.closePath();
|
|
break;
|
|
case 'triangle':
|
|
ctx.moveTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius);
|
|
rad += helpers.TWO_THIRDS_PI;
|
|
ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius);
|
|
rad += helpers.TWO_THIRDS_PI;
|
|
ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius);
|
|
ctx.closePath();
|
|
break;
|
|
case 'rectRounded':
|
|
// NOTE: the rounded rect implementation changed to use `arc` instead of
|
|
// `quadraticCurveTo` since it generates better results when rect is
|
|
// almost a circle. 0.516 (instead of 0.5) produces results with visually
|
|
// closer proportion to the previous impl and it is inscribed in the
|
|
// circle with `radius`. For more details, see the following PRs:
|
|
// https://github.com/chartjs/Chart.js/issues/5597
|
|
// https://github.com/chartjs/Chart.js/issues/5858
|
|
cornerRadius = radius * 0.516;
|
|
size = radius - cornerRadius;
|
|
xOffset = Math.cos(rad + helpers.QUARTER_PI) * size;
|
|
yOffset = Math.sin(rad + helpers.QUARTER_PI) * size;
|
|
ctx.arc(x - xOffset, y - yOffset, cornerRadius, rad - helpers.PI, rad - helpers.HALF_PI);
|
|
ctx.arc(x + yOffset, y - xOffset, cornerRadius, rad - helpers.HALF_PI, rad);
|
|
ctx.arc(x + xOffset, y + yOffset, cornerRadius, rad, rad + helpers.HALF_PI);
|
|
ctx.arc(x - yOffset, y + xOffset, cornerRadius, rad + helpers.HALF_PI, rad + helpers.PI);
|
|
ctx.closePath();
|
|
break;
|
|
case 'rect':
|
|
if (!rotation) {
|
|
size = Math.SQRT1_2 * radius;
|
|
ctx.rect(x - size, y - size, 2 * size, 2 * size);
|
|
break;
|
|
}
|
|
rad += helpers.QUARTER_PI;
|
|
/* falls through */
|
|
case 'rectRot':
|
|
xOffset = Math.cos(rad) * radius;
|
|
yOffset = Math.sin(rad) * radius;
|
|
ctx.moveTo(x - xOffset, y - yOffset);
|
|
ctx.lineTo(x + yOffset, y - xOffset);
|
|
ctx.lineTo(x + xOffset, y + yOffset);
|
|
ctx.lineTo(x - yOffset, y + xOffset);
|
|
ctx.closePath();
|
|
break;
|
|
case 'crossRot':
|
|
rad += helpers.QUARTER_PI;
|
|
/* falls through */
|
|
case 'cross':
|
|
xOffset = Math.cos(rad) * radius;
|
|
yOffset = Math.sin(rad) * radius;
|
|
ctx.moveTo(x - xOffset, y - yOffset);
|
|
ctx.lineTo(x + xOffset, y + yOffset);
|
|
ctx.moveTo(x + yOffset, y - xOffset);
|
|
ctx.lineTo(x - yOffset, y + xOffset);
|
|
break;
|
|
case 'star':
|
|
xOffset = Math.cos(rad) * radius;
|
|
yOffset = Math.sin(rad) * radius;
|
|
ctx.moveTo(x - xOffset, y - yOffset);
|
|
ctx.lineTo(x + xOffset, y + yOffset);
|
|
ctx.moveTo(x + yOffset, y - xOffset);
|
|
ctx.lineTo(x - yOffset, y + xOffset);
|
|
rad += helpers.QUARTER_PI;
|
|
xOffset = Math.cos(rad) * radius;
|
|
yOffset = Math.sin(rad) * radius;
|
|
ctx.moveTo(x - xOffset, y - yOffset);
|
|
ctx.lineTo(x + xOffset, y + yOffset);
|
|
ctx.moveTo(x + yOffset, y - xOffset);
|
|
ctx.lineTo(x - yOffset, y + xOffset);
|
|
break;
|
|
case 'line':
|
|
xOffset = Math.cos(rad) * radius;
|
|
yOffset = Math.sin(rad) * radius;
|
|
ctx.moveTo(x - xOffset, y - yOffset);
|
|
ctx.lineTo(x + xOffset, y + yOffset);
|
|
break;
|
|
case 'dash':
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + Math.cos(rad) * radius, y + Math.sin(rad) * radius);
|
|
break;
|
|
}
|
|
|
|
ctx.fill();
|
|
}
|
|
|
|
function calculateLabelSize(ctx, lines, fonts, strokeWidth) {
|
|
ctx.save();
|
|
const count = lines.length;
|
|
let width = 0;
|
|
let height = strokeWidth;
|
|
for (let i = 0; i < count; i++) {
|
|
const font = fonts[Math.min(i, fonts.length - 1)];
|
|
ctx.font = font.string;
|
|
const text = lines[i];
|
|
width = Math.max(width, ctx.measureText(text).width + strokeWidth);
|
|
height += font.lineHeight;
|
|
}
|
|
ctx.restore();
|
|
return {width, height};
|
|
}
|
|
|
|
function applyLabelDecoration(ctx, {x, y}, labels, fonts) {
|
|
ctx.beginPath();
|
|
let lhs = 0;
|
|
labels.forEach(function(l, i) {
|
|
const f = fonts[Math.min(i, fonts.length - 1)];
|
|
const lh = f.lineHeight;
|
|
ctx.font = f.string;
|
|
ctx.strokeText(l, x, y + lh / 2 + lhs);
|
|
lhs += lh;
|
|
});
|
|
ctx.stroke();
|
|
}
|
|
|
|
function applyLabelContent(ctx, {x, y}, labels, {fonts, colors}) {
|
|
let lhs = 0;
|
|
labels.forEach(function(l, i) {
|
|
const c = colors[Math.min(i, colors.length - 1)];
|
|
const f = fonts[Math.min(i, fonts.length - 1)];
|
|
const lh = f.lineHeight;
|
|
ctx.beginPath();
|
|
ctx.font = f.string;
|
|
ctx.fillStyle = c;
|
|
ctx.fillText(l, x, y + lh / 2 + lhs);
|
|
lhs += lh;
|
|
ctx.fill();
|
|
});
|
|
}
|
|
|
|
function getOpacity(value, elementValue) {
|
|
const opacity = helpers.isNumber(value) ? value : elementValue;
|
|
return helpers.isNumber(opacity) ? clamp(opacity, 0, 1) : 1;
|
|
}
|
|
|
|
const positions = ['left', 'bottom', 'top', 'right'];
|
|
|
|
/**
|
|
* @typedef { import('../../types/element').AnnotationElement } AnnotationElement
|
|
*/
|
|
|
|
/**
|
|
* Drawa the callout component for labels.
|
|
* @param {CanvasRenderingContext2D} ctx - chart canvas context
|
|
* @param {AnnotationElement} element - the label element
|
|
*/
|
|
function drawCallout(ctx, element) {
|
|
const {pointX, pointY, options} = element;
|
|
const callout = options.callout;
|
|
const calloutPosition = callout && callout.display && resolveCalloutPosition(element, callout);
|
|
if (!calloutPosition || isPointInRange(element, callout, calloutPosition)) {
|
|
return;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
const stroke = setBorderStyle(ctx, callout);
|
|
if (!stroke) {
|
|
return ctx.restore();
|
|
}
|
|
const {separatorStart, separatorEnd} = getCalloutSeparatorCoord(element, calloutPosition);
|
|
const {sideStart, sideEnd} = getCalloutSideCoord(element, calloutPosition, separatorStart);
|
|
if (callout.margin > 0 || options.borderWidth === 0) {
|
|
ctx.moveTo(separatorStart.x, separatorStart.y);
|
|
ctx.lineTo(separatorEnd.x, separatorEnd.y);
|
|
}
|
|
ctx.moveTo(sideStart.x, sideStart.y);
|
|
ctx.lineTo(sideEnd.x, sideEnd.y);
|
|
const rotatedPoint = rotated({x: pointX, y: pointY}, element.getCenterPoint(), helpers.toRadians(-element.rotation));
|
|
ctx.lineTo(rotatedPoint.x, rotatedPoint.y);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function getCalloutSeparatorCoord(element, position) {
|
|
const {x, y, x2, y2} = element;
|
|
const adjust = getCalloutSeparatorAdjust(element, position);
|
|
let separatorStart, separatorEnd;
|
|
if (position === 'left' || position === 'right') {
|
|
separatorStart = {x: x + adjust, y};
|
|
separatorEnd = {x: separatorStart.x, y: y2};
|
|
} else {
|
|
// position 'top' or 'bottom'
|
|
separatorStart = {x, y: y + adjust};
|
|
separatorEnd = {x: x2, y: separatorStart.y};
|
|
}
|
|
return {separatorStart, separatorEnd};
|
|
}
|
|
|
|
function getCalloutSeparatorAdjust(element, position) {
|
|
const {width, height, options} = element;
|
|
const adjust = options.callout.margin + options.borderWidth / 2;
|
|
if (position === 'right') {
|
|
return width + adjust;
|
|
} else if (position === 'bottom') {
|
|
return height + adjust;
|
|
}
|
|
return -adjust;
|
|
}
|
|
|
|
function getCalloutSideCoord(element, position, separatorStart) {
|
|
const {y, width, height, options} = element;
|
|
const start = options.callout.start;
|
|
const side = getCalloutSideAdjust(position, options.callout);
|
|
let sideStart, sideEnd;
|
|
if (position === 'left' || position === 'right') {
|
|
sideStart = {x: separatorStart.x, y: y + getSize(height, start)};
|
|
sideEnd = {x: sideStart.x + side, y: sideStart.y};
|
|
} else {
|
|
// position 'top' or 'bottom'
|
|
sideStart = {x: separatorStart.x + getSize(width, start), y: separatorStart.y};
|
|
sideEnd = {x: sideStart.x, y: sideStart.y + side};
|
|
}
|
|
return {sideStart, sideEnd};
|
|
}
|
|
|
|
function getCalloutSideAdjust(position, options) {
|
|
const side = options.side;
|
|
if (position === 'left' || position === 'top') {
|
|
return -side;
|
|
}
|
|
return side;
|
|
}
|
|
|
|
function resolveCalloutPosition(element, options) {
|
|
const position = options.position;
|
|
if (positions.includes(position)) {
|
|
return position;
|
|
}
|
|
return resolveCalloutAutoPosition(element, options);
|
|
}
|
|
|
|
function resolveCalloutAutoPosition(element, options) {
|
|
const {x, y, x2, y2, width, height, pointX, pointY, centerX, centerY, rotation} = element;
|
|
const center = {x: centerX, y: centerY};
|
|
const start = options.start;
|
|
const xAdjust = getSize(width, start);
|
|
const yAdjust = getSize(height, start);
|
|
const xPoints = [x, x + xAdjust, x + xAdjust, x2];
|
|
const yPoints = [y + yAdjust, y2, y, y2];
|
|
const result = [];
|
|
for (let index = 0; index < 4; index++) {
|
|
const rotatedPoint = rotated({x: xPoints[index], y: yPoints[index]}, center, helpers.toRadians(rotation));
|
|
result.push({
|
|
position: positions[index],
|
|
distance: helpers.distanceBetweenPoints(rotatedPoint, {x: pointX, y: pointY})
|
|
});
|
|
}
|
|
return result.sort((a, b) => a.distance - b.distance)[0].position;
|
|
}
|
|
|
|
function isPointInRange(element, callout, position) {
|
|
const {pointX, pointY} = element;
|
|
const margin = callout.margin;
|
|
let x = pointX;
|
|
let y = pointY;
|
|
if (position === 'left') {
|
|
x += margin;
|
|
} else if (position === 'right') {
|
|
x -= margin;
|
|
} else if (position === 'top') {
|
|
y += margin;
|
|
} else if (position === 'bottom') {
|
|
y -= margin;
|
|
}
|
|
return element.inRange(x, y);
|
|
}
|
|
|
|
const limitedLineScale = {
|
|
xScaleID: {min: 'xMin', max: 'xMax', start: 'left', end: 'right', startProp: 'x', endProp: 'x2'},
|
|
yScaleID: {min: 'yMin', max: 'yMax', start: 'bottom', end: 'top', startProp: 'y', endProp: 'y2'}
|
|
};
|
|
|
|
/**
|
|
* @typedef { import("chart.js").Chart } Chart
|
|
* @typedef { import("chart.js").Scale } Scale
|
|
* @typedef { import("chart.js").Point } Point
|
|
* @typedef { import('../../types/element').AnnotationBoxModel } AnnotationBoxModel
|
|
* @typedef { import('../../types/options').CoreAnnotationOptions } CoreAnnotationOptions
|
|
* @typedef { import('../../types/options').LineAnnotationOptions } LineAnnotationOptions
|
|
* @typedef { import('../../types/options').PointAnnotationOptions } PointAnnotationOptions
|
|
* @typedef { import('../../types/options').PolygonAnnotationOptions } PolygonAnnotationOptions
|
|
*/
|
|
|
|
/**
|
|
* @param {Scale} scale
|
|
* @param {number|string} value
|
|
* @param {number} fallback
|
|
* @returns {number}
|
|
*/
|
|
function scaleValue(scale, value, fallback) {
|
|
value = typeof value === 'number' ? value : scale.parse(value);
|
|
return helpers.isFinite(value) ? scale.getPixelForValue(value) : fallback;
|
|
}
|
|
|
|
/**
|
|
* Search the scale defined in chartjs by the axis related to the annotation options key.
|
|
* @param {{ [key: string]: Scale }} scales
|
|
* @param {CoreAnnotationOptions} options
|
|
* @param {string} key
|
|
* @returns {string}
|
|
*/
|
|
function retrieveScaleID(scales, options, key) {
|
|
const scaleID = options[key];
|
|
if (scaleID || key === 'scaleID') {
|
|
return scaleID;
|
|
}
|
|
const axis = key.charAt(0);
|
|
const axes = Object.values(scales).filter((scale) => scale.axis && scale.axis === axis);
|
|
if (axes.length) {
|
|
return axes[0].id;
|
|
}
|
|
return axis;
|
|
}
|
|
|
|
/**
|
|
* @param {Scale} scale
|
|
* @param {{min: number, max: number, start: number, end: number}} options
|
|
* @returns {{start: number, end: number}|undefined}
|
|
*/
|
|
function getDimensionByScale(scale, options) {
|
|
if (scale) {
|
|
const reverse = scale.options.reverse;
|
|
const start = scaleValue(scale, options.min, reverse ? options.end : options.start);
|
|
const end = scaleValue(scale, options.max, reverse ? options.start : options.end);
|
|
return {
|
|
start,
|
|
end
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {CoreAnnotationOptions} options
|
|
* @returns {Point}
|
|
*/
|
|
function getChartPoint(chart, options) {
|
|
const {chartArea, scales} = chart;
|
|
const xScale = scales[retrieveScaleID(scales, options, 'xScaleID')];
|
|
const yScale = scales[retrieveScaleID(scales, options, 'yScaleID')];
|
|
let x = chartArea.width / 2;
|
|
let y = chartArea.height / 2;
|
|
|
|
if (xScale) {
|
|
x = scaleValue(xScale, options.xValue, xScale.left + xScale.width / 2);
|
|
}
|
|
|
|
if (yScale) {
|
|
y = scaleValue(yScale, options.yValue, yScale.top + yScale.height / 2);
|
|
}
|
|
return {x, y};
|
|
}
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {CoreAnnotationOptions} options
|
|
* @returns {AnnotationBoxModel}
|
|
*/
|
|
function resolveBoxProperties(chart, options) {
|
|
const scales = chart.scales;
|
|
const xScale = scales[retrieveScaleID(scales, options, 'xScaleID')];
|
|
const yScale = scales[retrieveScaleID(scales, options, 'yScaleID')];
|
|
|
|
if (!xScale && !yScale) {
|
|
return {};
|
|
}
|
|
|
|
let {left: x, right: x2} = xScale || chart.chartArea;
|
|
let {top: y, bottom: y2} = yScale || chart.chartArea;
|
|
const xDim = getChartDimensionByScale(xScale, {min: options.xMin, max: options.xMax, start: x, end: x2});
|
|
x = xDim.start;
|
|
x2 = xDim.end;
|
|
const yDim = getChartDimensionByScale(yScale, {min: options.yMin, max: options.yMax, start: y2, end: y});
|
|
y = yDim.start;
|
|
y2 = yDim.end;
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
x2,
|
|
y2,
|
|
width: x2 - x,
|
|
height: y2 - y,
|
|
centerX: x + (x2 - x) / 2,
|
|
centerY: y + (y2 - y) / 2
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {PointAnnotationOptions|PolygonAnnotationOptions} options
|
|
* @returns {AnnotationBoxModel}
|
|
*/
|
|
function resolvePointProperties(chart, options) {
|
|
if (!isBoundToPoint(options)) {
|
|
const box = resolveBoxProperties(chart, options);
|
|
let radius = options.radius;
|
|
if (!radius || isNaN(radius)) {
|
|
radius = Math.min(box.width, box.height) / 2;
|
|
options.radius = radius;
|
|
}
|
|
const size = radius * 2;
|
|
const adjustCenterX = box.centerX + options.xAdjust;
|
|
const adjustCenterY = box.centerY + options.yAdjust;
|
|
return {
|
|
x: adjustCenterX - radius,
|
|
y: adjustCenterY - radius,
|
|
x2: adjustCenterX + radius,
|
|
y2: adjustCenterY + radius,
|
|
centerX: adjustCenterX,
|
|
centerY: adjustCenterY,
|
|
width: size,
|
|
height: size,
|
|
radius
|
|
};
|
|
}
|
|
return getChartCircle(chart, options);
|
|
}
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {LineAnnotationOptions} options
|
|
* @returns {AnnotationBoxModel}
|
|
*/
|
|
function resolveLineProperties(chart, options) {
|
|
const {scales, chartArea} = chart;
|
|
const scale = scales[options.scaleID];
|
|
const area = {x: chartArea.left, y: chartArea.top, x2: chartArea.right, y2: chartArea.bottom};
|
|
|
|
if (scale) {
|
|
resolveFullLineProperties(scale, area, options);
|
|
} else {
|
|
resolveLimitedLineProperties(scales, area, options);
|
|
}
|
|
return area;
|
|
}
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {CoreAnnotationOptions} options
|
|
* @param {boolean} [centerBased=false]
|
|
* @returns {AnnotationBoxModel}
|
|
*/
|
|
function resolveBoxAndLabelProperties(chart, options) {
|
|
const properties = resolveBoxProperties(chart, options);
|
|
properties.initProperties = initAnimationProperties(chart, properties, options);
|
|
properties.elements = [{
|
|
type: 'label',
|
|
optionScope: 'label',
|
|
properties: resolveLabelElementProperties$1(chart, properties, options),
|
|
initProperties: properties.initProperties
|
|
}];
|
|
return properties;
|
|
}
|
|
|
|
function getChartCircle(chart, options) {
|
|
const point = getChartPoint(chart, options);
|
|
const size = options.radius * 2;
|
|
return {
|
|
x: point.x - options.radius + options.xAdjust,
|
|
y: point.y - options.radius + options.yAdjust,
|
|
x2: point.x + options.radius + options.xAdjust,
|
|
y2: point.y + options.radius + options.yAdjust,
|
|
centerX: point.x + options.xAdjust,
|
|
centerY: point.y + options.yAdjust,
|
|
radius: options.radius,
|
|
width: size,
|
|
height: size
|
|
};
|
|
}
|
|
|
|
function getChartDimensionByScale(scale, options) {
|
|
const result = getDimensionByScale(scale, options) || options;
|
|
return {
|
|
start: Math.min(result.start, result.end),
|
|
end: Math.max(result.start, result.end)
|
|
};
|
|
}
|
|
|
|
function resolveFullLineProperties(scale, area, options) {
|
|
const min = scaleValue(scale, options.value, NaN);
|
|
const max = scaleValue(scale, options.endValue, min);
|
|
if (scale.isHorizontal()) {
|
|
area.x = min;
|
|
area.x2 = max;
|
|
} else {
|
|
area.y = min;
|
|
area.y2 = max;
|
|
}
|
|
}
|
|
|
|
function resolveLimitedLineProperties(scales, area, options) {
|
|
for (const scaleId of Object.keys(limitedLineScale)) {
|
|
const scale = scales[retrieveScaleID(scales, options, scaleId)];
|
|
if (scale) {
|
|
const {min, max, start, end, startProp, endProp} = limitedLineScale[scaleId];
|
|
const dim = getDimensionByScale(scale, {min: options[min], max: options[max], start: scale[start], end: scale[end]});
|
|
area[startProp] = dim.start;
|
|
area[endProp] = dim.end;
|
|
}
|
|
}
|
|
}
|
|
|
|
function calculateX({properties, options}, labelSize, position, padding) {
|
|
const {x: start, x2: end, width: size} = properties;
|
|
return calculatePosition({start, end, size, borderWidth: options.borderWidth}, {
|
|
position: position.x,
|
|
padding: {start: padding.left, end: padding.right},
|
|
adjust: options.label.xAdjust,
|
|
size: labelSize.width
|
|
});
|
|
}
|
|
|
|
function calculateY({properties, options}, labelSize, position, padding) {
|
|
const {y: start, y2: end, height: size} = properties;
|
|
return calculatePosition({start, end, size, borderWidth: options.borderWidth}, {
|
|
position: position.y,
|
|
padding: {start: padding.top, end: padding.bottom},
|
|
adjust: options.label.yAdjust,
|
|
size: labelSize.height
|
|
});
|
|
}
|
|
|
|
function calculatePosition(boxOpts, labelOpts) {
|
|
const {start, end, borderWidth} = boxOpts;
|
|
const {position, padding: {start: padStart, end: padEnd}, adjust} = labelOpts;
|
|
const availableSize = end - borderWidth - start - padStart - padEnd - labelOpts.size;
|
|
return start + borderWidth / 2 + adjust + getRelativePosition(availableSize, position);
|
|
}
|
|
|
|
function resolveLabelElementProperties$1(chart, properties, options) {
|
|
const label = options.label;
|
|
label.backgroundColor = 'transparent';
|
|
label.callout.display = false;
|
|
const position = toPosition(label.position);
|
|
const padding = helpers.toPadding(label.padding);
|
|
const labelSize = measureLabelSize(chart.ctx, label);
|
|
const x = calculateX({properties, options}, labelSize, position, padding);
|
|
const y = calculateY({properties, options}, labelSize, position, padding);
|
|
const width = labelSize.width + padding.width;
|
|
const height = labelSize.height + padding.height;
|
|
return {
|
|
x,
|
|
y,
|
|
x2: x + width,
|
|
y2: y + height,
|
|
width,
|
|
height,
|
|
centerX: x + width / 2,
|
|
centerY: y + height / 2,
|
|
rotation: label.rotation
|
|
};
|
|
|
|
}
|
|
|
|
const moveHooks = ['enter', 'leave'];
|
|
|
|
/**
|
|
* @typedef { import("chart.js").Chart } Chart
|
|
* @typedef { import('../../types/options').AnnotationPluginOptions } AnnotationPluginOptions
|
|
*/
|
|
|
|
const eventHooks = moveHooks.concat('click');
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {Object} state
|
|
* @param {AnnotationPluginOptions} options
|
|
*/
|
|
function updateListeners(chart, state, options) {
|
|
state.listened = loadHooks(options, eventHooks, state.listeners);
|
|
state.moveListened = false;
|
|
|
|
moveHooks.forEach(hook => {
|
|
if (helpers.isFunction(options[hook])) {
|
|
state.moveListened = true;
|
|
}
|
|
});
|
|
|
|
if (!state.listened || !state.moveListened) {
|
|
state.annotations.forEach(scope => {
|
|
if (!state.listened && helpers.isFunction(scope.click)) {
|
|
state.listened = true;
|
|
}
|
|
if (!state.moveListened) {
|
|
moveHooks.forEach(hook => {
|
|
if (helpers.isFunction(scope[hook])) {
|
|
state.listened = true;
|
|
state.moveListened = true;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Object} state
|
|
* @param {ChartEvent} event
|
|
* @param {AnnotationPluginOptions} options
|
|
* @return {boolean|undefined}
|
|
*/
|
|
function handleEvent(state, event, options) {
|
|
if (state.listened) {
|
|
switch (event.type) {
|
|
case 'mousemove':
|
|
case 'mouseout':
|
|
return handleMoveEvents(state, event, options);
|
|
case 'click':
|
|
return handleClickEvents(state, event, options);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleMoveEvents(state, event, options) {
|
|
if (!state.moveListened) {
|
|
return;
|
|
}
|
|
|
|
let elements;
|
|
|
|
if (event.type === 'mousemove') {
|
|
elements = getElements(state.visibleElements, event, options.interaction);
|
|
} else {
|
|
elements = [];
|
|
}
|
|
|
|
const previous = state.hovered;
|
|
state.hovered = elements;
|
|
|
|
const context = {state, event};
|
|
let changed = dispatchMoveEvents(context, 'leave', previous, elements);
|
|
return dispatchMoveEvents(context, 'enter', elements, previous) || changed;
|
|
}
|
|
|
|
function dispatchMoveEvents({state, event}, hook, elements, checkElements) {
|
|
let changed;
|
|
for (const element of elements) {
|
|
if (checkElements.indexOf(element) < 0) {
|
|
changed = dispatchEvent(element.options[hook] || state.listeners[hook], element, event) || changed;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function handleClickEvents(state, event, options) {
|
|
const listeners = state.listeners;
|
|
const elements = getElements(state.visibleElements, event, options.interaction);
|
|
let changed;
|
|
for (const element of elements) {
|
|
changed = dispatchEvent(element.options.click || listeners.click, element, event) || changed;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function dispatchEvent(handler, element, event) {
|
|
return helpers.callback(handler, [element.$context, event]) === true;
|
|
}
|
|
|
|
/**
|
|
* @typedef { import("chart.js").Chart } Chart
|
|
* @typedef { import('../../types/options').AnnotationPluginOptions } AnnotationPluginOptions
|
|
* @typedef { import('../../types/element').AnnotationElement } AnnotationElement
|
|
*/
|
|
|
|
const elementHooks = ['afterDraw', 'beforeDraw'];
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {Object} state
|
|
* @param {AnnotationPluginOptions} options
|
|
*/
|
|
function updateHooks(chart, state, options) {
|
|
const visibleElements = state.visibleElements;
|
|
state.hooked = loadHooks(options, elementHooks, state.hooks);
|
|
|
|
if (!state.hooked) {
|
|
visibleElements.forEach(scope => {
|
|
if (!state.hooked) {
|
|
elementHooks.forEach(hook => {
|
|
if (helpers.isFunction(scope.options[hook])) {
|
|
state.hooked = true;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Object} state
|
|
* @param {AnnotationElement} element
|
|
* @param {string} hook
|
|
*/
|
|
function invokeHook(state, element, hook) {
|
|
if (state.hooked) {
|
|
const callbackHook = element.options[hook] || state.hooks[hook];
|
|
return helpers.callback(callbackHook, [element.$context]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef { import("chart.js").Chart } Chart
|
|
* @typedef { import("chart.js").Scale } Scale
|
|
* @typedef { import('../../types/options').CoreAnnotationOptions } CoreAnnotationOptions
|
|
*/
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {Scale} scale
|
|
* @param {CoreAnnotationOptions[]} annotations
|
|
*/
|
|
function adjustScaleRange(chart, scale, annotations) {
|
|
const range = getScaleLimits(chart.scales, scale, annotations);
|
|
let changed = changeScaleLimit(scale, range, 'min', 'suggestedMin');
|
|
changed = changeScaleLimit(scale, range, 'max', 'suggestedMax') || changed;
|
|
if (changed && helpers.isFunction(scale.handleTickRangeOptions)) {
|
|
scale.handleTickRangeOptions();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {CoreAnnotationOptions[]} annotations
|
|
* @param {{ [key: string]: Scale }} scales
|
|
*/
|
|
function verifyScaleOptions(annotations, scales) {
|
|
for (const annotation of annotations) {
|
|
verifyScaleIDs(annotation, scales);
|
|
}
|
|
}
|
|
|
|
function changeScaleLimit(scale, range, limit, suggestedLimit) {
|
|
if (helpers.isFinite(range[limit]) && !scaleLimitDefined(scale.options, limit, suggestedLimit)) {
|
|
const changed = scale[limit] !== range[limit];
|
|
scale[limit] = range[limit];
|
|
return changed;
|
|
}
|
|
}
|
|
|
|
function scaleLimitDefined(scaleOptions, limit, suggestedLimit) {
|
|
return helpers.defined(scaleOptions[limit]) || helpers.defined(scaleOptions[suggestedLimit]);
|
|
}
|
|
|
|
function verifyScaleIDs(annotation, scales) {
|
|
for (const key of ['scaleID', 'xScaleID', 'yScaleID']) {
|
|
const scaleID = retrieveScaleID(scales, annotation, key);
|
|
if (scaleID && !scales[scaleID] && verifyProperties(annotation, key)) {
|
|
console.warn(`No scale found with id '${scaleID}' for annotation '${annotation.id}'`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function verifyProperties(annotation, key) {
|
|
if (key === 'scaleID') {
|
|
return true;
|
|
}
|
|
const axis = key.charAt(0);
|
|
for (const prop of ['Min', 'Max', 'Value']) {
|
|
if (helpers.defined(annotation[axis + prop])) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getScaleLimits(scales, scale, annotations) {
|
|
const axis = scale.axis;
|
|
const scaleID = scale.id;
|
|
const scaleIDOption = axis + 'ScaleID';
|
|
const limits = {
|
|
min: helpers.valueOrDefault(scale.min, Number.NEGATIVE_INFINITY),
|
|
max: helpers.valueOrDefault(scale.max, Number.POSITIVE_INFINITY)
|
|
};
|
|
for (const annotation of annotations) {
|
|
if (annotation.scaleID === scaleID) {
|
|
updateLimits(annotation, scale, ['value', 'endValue'], limits);
|
|
} else if (retrieveScaleID(scales, annotation, scaleIDOption) === scaleID) {
|
|
updateLimits(annotation, scale, [axis + 'Min', axis + 'Max', axis + 'Value'], limits);
|
|
}
|
|
}
|
|
return limits;
|
|
}
|
|
|
|
function updateLimits(annotation, scale, props, limits) {
|
|
for (const prop of props) {
|
|
const raw = annotation[prop];
|
|
if (helpers.defined(raw)) {
|
|
const value = scale.parse(raw);
|
|
limits.min = Math.min(limits.min, value);
|
|
limits.max = Math.max(limits.max, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
class BoxAnnotation extends chart_js.Element {
|
|
|
|
inRange(mouseX, mouseY, axis, useFinalPosition) {
|
|
const {x, y} = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), helpers.toRadians(-this.options.rotation));
|
|
return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, this.options);
|
|
}
|
|
|
|
getCenterPoint(useFinalPosition) {
|
|
return getElementCenterPoint(this, useFinalPosition);
|
|
}
|
|
|
|
draw(ctx) {
|
|
ctx.save();
|
|
translate(ctx, this.getCenterPoint(), this.options.rotation);
|
|
drawBox(ctx, this, this.options);
|
|
ctx.restore();
|
|
}
|
|
|
|
get label() {
|
|
return this.elements && this.elements[0];
|
|
}
|
|
|
|
resolveElementProperties(chart, options) {
|
|
return resolveBoxAndLabelProperties(chart, options);
|
|
}
|
|
}
|
|
|
|
BoxAnnotation.id = 'boxAnnotation';
|
|
|
|
BoxAnnotation.defaults = {
|
|
adjustScaleRange: true,
|
|
backgroundShadowColor: 'transparent',
|
|
borderCapStyle: 'butt',
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderJoinStyle: 'miter',
|
|
borderRadius: 0,
|
|
borderShadowColor: 'transparent',
|
|
borderWidth: 1,
|
|
display: true,
|
|
init: undefined,
|
|
hitTolerance: 0,
|
|
label: {
|
|
backgroundColor: 'transparent',
|
|
borderWidth: 0,
|
|
callout: {
|
|
display: false
|
|
},
|
|
color: 'black',
|
|
content: null,
|
|
display: false,
|
|
drawTime: undefined,
|
|
font: {
|
|
family: undefined,
|
|
lineHeight: undefined,
|
|
size: undefined,
|
|
style: undefined,
|
|
weight: 'bold'
|
|
},
|
|
height: undefined,
|
|
hitTolerance: undefined,
|
|
opacity: undefined,
|
|
padding: 6,
|
|
position: 'center',
|
|
rotation: undefined,
|
|
textAlign: 'start',
|
|
textStrokeColor: undefined,
|
|
textStrokeWidth: 0,
|
|
width: undefined,
|
|
xAdjust: 0,
|
|
yAdjust: 0,
|
|
z: undefined
|
|
},
|
|
rotation: 0,
|
|
shadowBlur: 0,
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
xMax: undefined,
|
|
xMin: undefined,
|
|
xScaleID: undefined,
|
|
yMax: undefined,
|
|
yMin: undefined,
|
|
yScaleID: undefined,
|
|
z: 0
|
|
};
|
|
|
|
BoxAnnotation.defaultRoutes = {
|
|
borderColor: 'color',
|
|
backgroundColor: 'color'
|
|
};
|
|
|
|
BoxAnnotation.descriptors = {
|
|
label: {
|
|
_fallback: true
|
|
}
|
|
};
|
|
|
|
class DoughnutLabelAnnotation extends chart_js.Element {
|
|
|
|
inRange(mouseX, mouseY, axis, useFinalPosition) {
|
|
return inLabelRange(
|
|
{x: mouseX, y: mouseY},
|
|
{rect: this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), center: this.getCenterPoint(useFinalPosition)},
|
|
axis,
|
|
{rotation: this.rotation, borderWidth: 0, hitTolerance: this.options.hitTolerance}
|
|
);
|
|
}
|
|
|
|
getCenterPoint(useFinalPosition) {
|
|
return getElementCenterPoint(this, useFinalPosition);
|
|
}
|
|
|
|
draw(ctx) {
|
|
const options = this.options;
|
|
if (!options.display || !options.content) {
|
|
return;
|
|
}
|
|
drawBackground(ctx, this);
|
|
ctx.save();
|
|
translate(ctx, this.getCenterPoint(), this.rotation);
|
|
drawLabel(ctx, this, options, this._fitRatio);
|
|
ctx.restore();
|
|
}
|
|
|
|
resolveElementProperties(chart, options) {
|
|
const meta = getDatasetMeta(chart, options);
|
|
if (!meta) {
|
|
return {};
|
|
}
|
|
const {controllerMeta, point, radius} = getControllerMeta(chart, options, meta);
|
|
let labelSize = measureLabelSize(chart.ctx, options);
|
|
const _fitRatio = getFitRatio(labelSize, radius);
|
|
if (shouldFit(options, _fitRatio)) {
|
|
labelSize = {width: labelSize.width * _fitRatio, height: labelSize.height * _fitRatio};
|
|
}
|
|
const {position, xAdjust, yAdjust} = options;
|
|
const boxSize = measureLabelRectangle(point, labelSize, {borderWidth: 0, position, xAdjust, yAdjust});
|
|
return {
|
|
initProperties: initAnimationProperties(chart, boxSize, options),
|
|
...boxSize,
|
|
...controllerMeta,
|
|
rotation: options.rotation,
|
|
_fitRatio
|
|
};
|
|
}
|
|
}
|
|
|
|
DoughnutLabelAnnotation.id = 'doughnutLabelAnnotation';
|
|
|
|
DoughnutLabelAnnotation.defaults = {
|
|
autoFit: true,
|
|
autoHide: true,
|
|
backgroundColor: 'transparent',
|
|
backgroundShadowColor: 'transparent',
|
|
borderColor: 'transparent',
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderJoinStyle: 'miter',
|
|
borderShadowColor: 'transparent',
|
|
borderWidth: 0,
|
|
color: 'black',
|
|
content: null,
|
|
display: true,
|
|
font: {
|
|
family: undefined,
|
|
lineHeight: undefined,
|
|
size: undefined,
|
|
style: undefined,
|
|
weight: undefined
|
|
},
|
|
height: undefined,
|
|
hitTolerance: 0,
|
|
init: undefined,
|
|
opacity: undefined,
|
|
position: 'center',
|
|
rotation: 0,
|
|
shadowBlur: 0,
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
spacing: 1,
|
|
textAlign: 'center',
|
|
textStrokeColor: undefined,
|
|
textStrokeWidth: 0,
|
|
width: undefined,
|
|
xAdjust: 0,
|
|
yAdjust: 0
|
|
};
|
|
|
|
DoughnutLabelAnnotation.defaultRoutes = {
|
|
};
|
|
|
|
function getDatasetMeta(chart, options) {
|
|
return chart.getSortedVisibleDatasetMetas().reduce(function(result, value) {
|
|
const controller = value.controller;
|
|
if (controller instanceof chart_js.DoughnutController &&
|
|
isControllerVisible(chart, options, value.data) &&
|
|
(!result || controller.innerRadius < result.controller.innerRadius) &&
|
|
controller.options.circumference >= 90) {
|
|
return value;
|
|
}
|
|
return result;
|
|
}, undefined);
|
|
}
|
|
|
|
function isControllerVisible(chart, options, elements) {
|
|
if (!options.autoHide) {
|
|
return true;
|
|
}
|
|
for (let i = 0; i < elements.length; i++) {
|
|
if (!elements[i].hidden && chart.getDataVisibility(i)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function getControllerMeta({chartArea}, options, meta) {
|
|
const {left, top, right, bottom} = chartArea;
|
|
const {innerRadius, offsetX, offsetY} = meta.controller;
|
|
const x = (left + right) / 2 + offsetX;
|
|
const y = (top + bottom) / 2 + offsetY;
|
|
const square = {
|
|
left: Math.max(x - innerRadius, left),
|
|
right: Math.min(x + innerRadius, right),
|
|
top: Math.max(y - innerRadius, top),
|
|
bottom: Math.min(y + innerRadius, bottom)
|
|
};
|
|
const point = {
|
|
x: (square.left + square.right) / 2,
|
|
y: (square.top + square.bottom) / 2
|
|
};
|
|
const space = options.spacing + options.borderWidth / 2;
|
|
const _radius = innerRadius - space;
|
|
const _counterclockwise = point.y > y;
|
|
const side = _counterclockwise ? top + space : bottom - space;
|
|
const angles = getAngles(side, x, y, _radius);
|
|
const controllerMeta = {
|
|
_centerX: x,
|
|
_centerY: y,
|
|
_radius,
|
|
_counterclockwise,
|
|
...angles
|
|
};
|
|
return {
|
|
controllerMeta,
|
|
point,
|
|
radius: Math.min(innerRadius, Math.min(square.right - square.left, square.bottom - square.top) / 2)
|
|
};
|
|
}
|
|
|
|
function getFitRatio({width, height}, radius) {
|
|
const hypo = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2));
|
|
return (radius * 2) / hypo;
|
|
}
|
|
|
|
function getAngles(y, centerX, centerY, radius) {
|
|
const yk2 = Math.pow(centerY - y, 2);
|
|
const r2 = Math.pow(radius, 2);
|
|
const b = centerX * -2;
|
|
const c = Math.pow(centerX, 2) + yk2 - r2;
|
|
const delta = Math.pow(b, 2) - (4 * c);
|
|
if (delta <= 0) {
|
|
return {
|
|
_startAngle: 0,
|
|
_endAngle: helpers.TAU
|
|
};
|
|
}
|
|
const start = (-b - Math.sqrt(delta)) / 2;
|
|
const end = (-b + Math.sqrt(delta)) / 2;
|
|
return {
|
|
_startAngle: helpers.getAngleFromPoint({x: centerX, y: centerY}, {x: start, y}).angle,
|
|
_endAngle: helpers.getAngleFromPoint({x: centerX, y: centerY}, {x: end, y}).angle
|
|
};
|
|
}
|
|
|
|
function drawBackground(ctx, element) {
|
|
const {_centerX, _centerY, _radius, _startAngle, _endAngle, _counterclockwise, options} = element;
|
|
ctx.save();
|
|
const stroke = setBorderStyle(ctx, options);
|
|
ctx.fillStyle = options.backgroundColor;
|
|
ctx.beginPath();
|
|
ctx.arc(_centerX, _centerY, _radius, _startAngle, _endAngle, _counterclockwise);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
if (stroke) {
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
class LabelAnnotation extends chart_js.Element {
|
|
|
|
inRange(mouseX, mouseY, axis, useFinalPosition) {
|
|
return inLabelRange(
|
|
{x: mouseX, y: mouseY},
|
|
{rect: this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), center: this.getCenterPoint(useFinalPosition)},
|
|
axis,
|
|
{rotation: this.rotation, borderWidth: this.options.borderWidth, hitTolerance: this.options.hitTolerance}
|
|
);
|
|
}
|
|
|
|
getCenterPoint(useFinalPosition) {
|
|
return getElementCenterPoint(this, useFinalPosition);
|
|
}
|
|
|
|
draw(ctx) {
|
|
const options = this.options;
|
|
const visible = !helpers.defined(this._visible) || this._visible;
|
|
if (!options.display || !options.content || !visible) {
|
|
return;
|
|
}
|
|
ctx.save();
|
|
translate(ctx, this.getCenterPoint(), this.rotation);
|
|
drawCallout(ctx, this);
|
|
drawBox(ctx, this, options);
|
|
drawLabel(ctx, getLabelSize(this), options);
|
|
ctx.restore();
|
|
}
|
|
|
|
resolveElementProperties(chart, options) {
|
|
let point;
|
|
if (!isBoundToPoint(options)) {
|
|
const {centerX, centerY} = resolveBoxProperties(chart, options);
|
|
point = {x: centerX, y: centerY};
|
|
} else {
|
|
point = getChartPoint(chart, options);
|
|
}
|
|
const padding = helpers.toPadding(options.padding);
|
|
const labelSize = measureLabelSize(chart.ctx, options);
|
|
const boxSize = measureLabelRectangle(point, labelSize, options, padding);
|
|
return {
|
|
initProperties: initAnimationProperties(chart, boxSize, options),
|
|
pointX: point.x,
|
|
pointY: point.y,
|
|
...boxSize,
|
|
rotation: options.rotation
|
|
};
|
|
}
|
|
}
|
|
|
|
LabelAnnotation.id = 'labelAnnotation';
|
|
|
|
LabelAnnotation.defaults = {
|
|
adjustScaleRange: true,
|
|
backgroundColor: 'transparent',
|
|
backgroundShadowColor: 'transparent',
|
|
borderCapStyle: 'butt',
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderJoinStyle: 'miter',
|
|
borderRadius: 0,
|
|
borderShadowColor: 'transparent',
|
|
borderWidth: 0,
|
|
callout: {
|
|
borderCapStyle: 'butt',
|
|
borderColor: undefined,
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderJoinStyle: 'miter',
|
|
borderWidth: 1,
|
|
display: false,
|
|
margin: 5,
|
|
position: 'auto',
|
|
side: 5,
|
|
start: '50%',
|
|
},
|
|
color: 'black',
|
|
content: null,
|
|
display: true,
|
|
font: {
|
|
family: undefined,
|
|
lineHeight: undefined,
|
|
size: undefined,
|
|
style: undefined,
|
|
weight: undefined
|
|
},
|
|
height: undefined,
|
|
hitTolerance: 0,
|
|
init: undefined,
|
|
opacity: undefined,
|
|
padding: 6,
|
|
position: 'center',
|
|
rotation: 0,
|
|
shadowBlur: 0,
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
textAlign: 'center',
|
|
textStrokeColor: undefined,
|
|
textStrokeWidth: 0,
|
|
width: undefined,
|
|
xAdjust: 0,
|
|
xMax: undefined,
|
|
xMin: undefined,
|
|
xScaleID: undefined,
|
|
xValue: undefined,
|
|
yAdjust: 0,
|
|
yMax: undefined,
|
|
yMin: undefined,
|
|
yScaleID: undefined,
|
|
yValue: undefined,
|
|
z: 0
|
|
};
|
|
|
|
LabelAnnotation.defaultRoutes = {
|
|
borderColor: 'color'
|
|
};
|
|
|
|
function getLabelSize({x, y, width, height, options}) {
|
|
const hBorderWidth = options.borderWidth / 2;
|
|
const padding = helpers.toPadding(options.padding);
|
|
return {
|
|
x: x + padding.left + hBorderWidth,
|
|
y: y + padding.top + hBorderWidth,
|
|
width: width - padding.left - padding.right - options.borderWidth,
|
|
height: height - padding.top - padding.bottom - options.borderWidth
|
|
};
|
|
}
|
|
|
|
const pointInLine = (p1, p2, t) => ({x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y)});
|
|
const interpolateX = (y, p1, p2) => pointInLine(p1, p2, Math.abs((y - p1.y) / (p2.y - p1.y))).x;
|
|
const interpolateY = (x, p1, p2) => pointInLine(p1, p2, Math.abs((x - p1.x) / (p2.x - p1.x))).y;
|
|
const sqr = v => v * v;
|
|
const rangeLimit = (mouseX, mouseY, {x, y, x2, y2}, axis) => axis === 'y' ? {start: Math.min(y, y2), end: Math.max(y, y2), value: mouseY} : {start: Math.min(x, x2), end: Math.max(x, x2), value: mouseX};
|
|
// http://www.independent-software.com/determining-coordinates-on-a-html-canvas-bezier-curve.html
|
|
const coordInCurve = (start, cp, end, t) => (1 - t) * (1 - t) * start + 2 * (1 - t) * t * cp + t * t * end;
|
|
const pointInCurve = (start, cp, end, t) => ({x: coordInCurve(start.x, cp.x, end.x, t), y: coordInCurve(start.y, cp.y, end.y, t)});
|
|
const coordAngleInCurve = (start, cp, end, t) => 2 * (1 - t) * (cp - start) + 2 * t * (end - cp);
|
|
const angleInCurve = (start, cp, end, t) => -Math.atan2(coordAngleInCurve(start.x, cp.x, end.x, t), coordAngleInCurve(start.y, cp.y, end.y, t)) + 0.5 * helpers.PI;
|
|
|
|
class LineAnnotation extends chart_js.Element {
|
|
|
|
inRange(mouseX, mouseY, axis, useFinalPosition) {
|
|
const hitSize = (this.options.borderWidth + this.options.hitTolerance) / 2;
|
|
if (axis !== 'x' && axis !== 'y') {
|
|
const point = {mouseX, mouseY};
|
|
const {path, ctx} = this;
|
|
if (path) {
|
|
setBorderStyle(ctx, this.options);
|
|
ctx.lineWidth += this.options.hitTolerance;
|
|
const {chart} = this.$context;
|
|
const mx = mouseX * chart.currentDevicePixelRatio;
|
|
const my = mouseY * chart.currentDevicePixelRatio;
|
|
const result = ctx.isPointInStroke(path, mx, my) || isOnLabel(this, point, useFinalPosition);
|
|
ctx.restore();
|
|
return result;
|
|
}
|
|
const epsilon = sqr(hitSize);
|
|
return intersects(this, point, epsilon, useFinalPosition) || isOnLabel(this, point, useFinalPosition);
|
|
}
|
|
return inAxisRange(this, {mouseX, mouseY}, axis, {hitSize, useFinalPosition});
|
|
}
|
|
|
|
getCenterPoint(useFinalPosition) {
|
|
return getElementCenterPoint(this, useFinalPosition);
|
|
}
|
|
|
|
draw(ctx) {
|
|
const {x, y, x2, y2, cp, options} = this;
|
|
|
|
ctx.save();
|
|
if (!setBorderStyle(ctx, options)) {
|
|
// no border width, then line is not drawn
|
|
return ctx.restore();
|
|
}
|
|
setShadowStyle(ctx, options);
|
|
|
|
const length = Math.sqrt(Math.pow(x2 - x, 2) + Math.pow(y2 - y, 2));
|
|
if (options.curve && cp) {
|
|
drawCurve(ctx, this, cp, length);
|
|
return ctx.restore();
|
|
}
|
|
const {startOpts, endOpts, startAdjust, endAdjust} = getArrowHeads(this);
|
|
const angle = Math.atan2(y2 - y, x2 - x);
|
|
ctx.translate(x, y);
|
|
ctx.rotate(angle);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0 + startAdjust, 0);
|
|
ctx.lineTo(length - endAdjust, 0);
|
|
ctx.shadowColor = options.borderShadowColor;
|
|
ctx.stroke();
|
|
drawArrowHead(ctx, 0, startAdjust, startOpts);
|
|
drawArrowHead(ctx, length, -endAdjust, endOpts);
|
|
ctx.restore();
|
|
}
|
|
|
|
get label() {
|
|
return this.elements && this.elements[0];
|
|
}
|
|
|
|
resolveElementProperties(chart, options) {
|
|
const area = resolveLineProperties(chart, options);
|
|
const {x, y, x2, y2} = area;
|
|
const inside = isLineInArea(area, chart.chartArea);
|
|
const properties = inside
|
|
? limitLineToArea({x, y}, {x: x2, y: y2}, chart.chartArea)
|
|
: {x, y, x2, y2, width: Math.abs(x2 - x), height: Math.abs(y2 - y)};
|
|
properties.centerX = (x2 + x) / 2;
|
|
properties.centerY = (y2 + y) / 2;
|
|
properties.initProperties = initAnimationProperties(chart, properties, options);
|
|
if (options.curve) {
|
|
const p1 = {x: properties.x, y: properties.y};
|
|
const p2 = {x: properties.x2, y: properties.y2};
|
|
properties.cp = getControlPoint(properties, options, helpers.distanceBetweenPoints(p1, p2));
|
|
}
|
|
const labelProperties = resolveLabelElementProperties(chart, properties, options.label);
|
|
// additonal prop to manage zoom/pan
|
|
labelProperties._visible = inside;
|
|
|
|
properties.elements = [{
|
|
type: 'label',
|
|
optionScope: 'label',
|
|
properties: labelProperties,
|
|
initProperties: properties.initProperties
|
|
}];
|
|
return properties;
|
|
}
|
|
}
|
|
|
|
LineAnnotation.id = 'lineAnnotation';
|
|
|
|
const arrowHeadsDefaults = {
|
|
backgroundColor: undefined,
|
|
backgroundShadowColor: undefined,
|
|
borderColor: undefined,
|
|
borderDash: undefined,
|
|
borderDashOffset: undefined,
|
|
borderShadowColor: undefined,
|
|
borderWidth: undefined,
|
|
display: undefined,
|
|
fill: undefined,
|
|
length: undefined,
|
|
shadowBlur: undefined,
|
|
shadowOffsetX: undefined,
|
|
shadowOffsetY: undefined,
|
|
width: undefined
|
|
};
|
|
|
|
LineAnnotation.defaults = {
|
|
adjustScaleRange: true,
|
|
arrowHeads: {
|
|
display: false,
|
|
end: Object.assign({}, arrowHeadsDefaults),
|
|
fill: false,
|
|
length: 12,
|
|
start: Object.assign({}, arrowHeadsDefaults),
|
|
width: 6
|
|
},
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderShadowColor: 'transparent',
|
|
borderWidth: 2,
|
|
curve: false,
|
|
controlPoint: {
|
|
y: '-50%'
|
|
},
|
|
display: true,
|
|
endValue: undefined,
|
|
init: undefined,
|
|
hitTolerance: 0,
|
|
label: {
|
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
backgroundShadowColor: 'transparent',
|
|
borderCapStyle: 'butt',
|
|
borderColor: 'black',
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderJoinStyle: 'miter',
|
|
borderRadius: 6,
|
|
borderShadowColor: 'transparent',
|
|
borderWidth: 0,
|
|
callout: Object.assign({}, LabelAnnotation.defaults.callout),
|
|
color: '#fff',
|
|
content: null,
|
|
display: false,
|
|
drawTime: undefined,
|
|
font: {
|
|
family: undefined,
|
|
lineHeight: undefined,
|
|
size: undefined,
|
|
style: undefined,
|
|
weight: 'bold'
|
|
},
|
|
height: undefined,
|
|
hitTolerance: undefined,
|
|
opacity: undefined,
|
|
padding: 6,
|
|
position: 'center',
|
|
rotation: 0,
|
|
shadowBlur: 0,
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
textAlign: 'center',
|
|
textStrokeColor: undefined,
|
|
textStrokeWidth: 0,
|
|
width: undefined,
|
|
xAdjust: 0,
|
|
yAdjust: 0,
|
|
z: undefined
|
|
},
|
|
scaleID: undefined,
|
|
shadowBlur: 0,
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
value: undefined,
|
|
xMax: undefined,
|
|
xMin: undefined,
|
|
xScaleID: undefined,
|
|
yMax: undefined,
|
|
yMin: undefined,
|
|
yScaleID: undefined,
|
|
z: 0
|
|
};
|
|
|
|
LineAnnotation.descriptors = {
|
|
arrowHeads: {
|
|
start: {
|
|
_fallback: true
|
|
},
|
|
end: {
|
|
_fallback: true
|
|
},
|
|
_fallback: true
|
|
}
|
|
};
|
|
|
|
LineAnnotation.defaultRoutes = {
|
|
borderColor: 'color'
|
|
};
|
|
|
|
function inAxisRange(element, {mouseX, mouseY}, axis, {hitSize, useFinalPosition}) {
|
|
const limit = rangeLimit(mouseX, mouseY, element.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis);
|
|
return inLimit(limit, hitSize) || isOnLabel(element, {mouseX, mouseY}, useFinalPosition, axis);
|
|
}
|
|
|
|
function isLineInArea({x, y, x2, y2}, {top, right, bottom, left}) {
|
|
return !(
|
|
(x < left && x2 < left) ||
|
|
(x > right && x2 > right) ||
|
|
(y < top && y2 < top) ||
|
|
(y > bottom && y2 > bottom)
|
|
);
|
|
}
|
|
|
|
function limitPointToArea({x, y}, p2, {top, right, bottom, left}) {
|
|
if (x < left) {
|
|
y = interpolateY(left, {x, y}, p2);
|
|
x = left;
|
|
}
|
|
if (x > right) {
|
|
y = interpolateY(right, {x, y}, p2);
|
|
x = right;
|
|
}
|
|
if (y < top) {
|
|
x = interpolateX(top, {x, y}, p2);
|
|
y = top;
|
|
}
|
|
if (y > bottom) {
|
|
x = interpolateX(bottom, {x, y}, p2);
|
|
y = bottom;
|
|
}
|
|
return {x, y};
|
|
}
|
|
|
|
function limitLineToArea(p1, p2, area) {
|
|
const {x, y} = limitPointToArea(p1, p2, area);
|
|
const {x: x2, y: y2} = limitPointToArea(p2, p1, area);
|
|
return {x, y, x2, y2, width: Math.abs(x2 - x), height: Math.abs(y2 - y)};
|
|
}
|
|
|
|
function intersects(element, {mouseX, mouseY}, epsilon = EPSILON, useFinalPosition) {
|
|
// Adapted from https://stackoverflow.com/a/6853926/25507
|
|
const {x: x1, y: y1, x2, y2} = element.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition);
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
const lenSq = sqr(dx) + sqr(dy);
|
|
const t = lenSq === 0 ? -1 : ((mouseX - x1) * dx + (mouseY - y1) * dy) / lenSq;
|
|
|
|
let xx, yy;
|
|
if (t < 0) {
|
|
xx = x1;
|
|
yy = y1;
|
|
} else if (t > 1) {
|
|
xx = x2;
|
|
yy = y2;
|
|
} else {
|
|
xx = x1 + t * dx;
|
|
yy = y1 + t * dy;
|
|
}
|
|
return (sqr(mouseX - xx) + sqr(mouseY - yy)) <= epsilon;
|
|
}
|
|
|
|
function isOnLabel(element, {mouseX, mouseY}, useFinalPosition, axis) {
|
|
const label = element.label;
|
|
return label.options.display && label.inRange(mouseX, mouseY, axis, useFinalPosition);
|
|
}
|
|
|
|
function resolveLabelElementProperties(chart, properties, options) {
|
|
const borderWidth = options.borderWidth;
|
|
const padding = helpers.toPadding(options.padding);
|
|
const textSize = measureLabelSize(chart.ctx, options);
|
|
const width = textSize.width + padding.width + borderWidth;
|
|
const height = textSize.height + padding.height + borderWidth;
|
|
return calculateLabelPosition(properties, options, {width, height, padding}, chart.chartArea);
|
|
}
|
|
|
|
function calculateAutoRotation(properties) {
|
|
const {x, y, x2, y2} = properties;
|
|
const rotation = Math.atan2(y2 - y, x2 - x);
|
|
// Flip the rotation if it goes > PI/2 or < -PI/2, so label stays upright
|
|
return rotation > helpers.PI / 2 ? rotation - helpers.PI : rotation < helpers.PI / -2 ? rotation + helpers.PI : rotation;
|
|
}
|
|
|
|
function calculateLabelPosition(properties, label, sizes, chartArea) {
|
|
const {width, height, padding} = sizes;
|
|
const {xAdjust, yAdjust} = label;
|
|
const p1 = {x: properties.x, y: properties.y};
|
|
const p2 = {x: properties.x2, y: properties.y2};
|
|
const rotation = label.rotation === 'auto' ? calculateAutoRotation(properties) : helpers.toRadians(label.rotation);
|
|
const size = rotatedSize(width, height, rotation);
|
|
const t = calculateT(properties, label, {labelSize: size, padding}, chartArea);
|
|
const pt = properties.cp ? pointInCurve(p1, properties.cp, p2, t) : pointInLine(p1, p2, t);
|
|
const xCoordinateSizes = {size: size.w, min: chartArea.left, max: chartArea.right, padding: padding.left};
|
|
const yCoordinateSizes = {size: size.h, min: chartArea.top, max: chartArea.bottom, padding: padding.top};
|
|
const centerX = adjustLabelCoordinate(pt.x, xCoordinateSizes) + xAdjust;
|
|
const centerY = adjustLabelCoordinate(pt.y, yCoordinateSizes) + yAdjust;
|
|
return {
|
|
x: centerX - (width / 2),
|
|
y: centerY - (height / 2),
|
|
x2: centerX + (width / 2),
|
|
y2: centerY + (height / 2),
|
|
centerX,
|
|
centerY,
|
|
pointX: pt.x,
|
|
pointY: pt.y,
|
|
width,
|
|
height,
|
|
rotation: helpers.toDegrees(rotation)
|
|
};
|
|
}
|
|
|
|
function rotatedSize(width, height, rotation) {
|
|
const cos = Math.cos(rotation);
|
|
const sin = Math.sin(rotation);
|
|
return {
|
|
w: Math.abs(width * cos) + Math.abs(height * sin),
|
|
h: Math.abs(width * sin) + Math.abs(height * cos)
|
|
};
|
|
}
|
|
|
|
function calculateT(properties, label, sizes, chartArea) {
|
|
let t;
|
|
const space = spaceAround(properties, chartArea);
|
|
if (label.position === 'start') {
|
|
t = calculateTAdjust({w: properties.x2 - properties.x, h: properties.y2 - properties.y}, sizes, label, space);
|
|
} else if (label.position === 'end') {
|
|
t = 1 - calculateTAdjust({w: properties.x - properties.x2, h: properties.y - properties.y2}, sizes, label, space);
|
|
} else {
|
|
t = getRelativePosition(1, label.position);
|
|
}
|
|
return t;
|
|
}
|
|
|
|
function calculateTAdjust(lineSize, sizes, label, space) {
|
|
const {labelSize, padding} = sizes;
|
|
const lineW = lineSize.w * space.dx;
|
|
const lineH = lineSize.h * space.dy;
|
|
const x = (lineW > 0) && ((labelSize.w / 2 + padding.left - space.x) / lineW);
|
|
const y = (lineH > 0) && ((labelSize.h / 2 + padding.top - space.y) / lineH);
|
|
return clamp(Math.max(x, y), 0, 0.25);
|
|
}
|
|
|
|
function spaceAround(properties, chartArea) {
|
|
const {x, x2, y, y2} = properties;
|
|
const t = Math.min(y, y2) - chartArea.top;
|
|
const l = Math.min(x, x2) - chartArea.left;
|
|
const b = chartArea.bottom - Math.max(y, y2);
|
|
const r = chartArea.right - Math.max(x, x2);
|
|
return {
|
|
x: Math.min(l, r),
|
|
y: Math.min(t, b),
|
|
dx: l <= r ? 1 : -1,
|
|
dy: t <= b ? 1 : -1
|
|
};
|
|
}
|
|
|
|
function adjustLabelCoordinate(coordinate, labelSizes) {
|
|
const {size, min, max, padding} = labelSizes;
|
|
const halfSize = size / 2;
|
|
if (size > max - min) {
|
|
// if it does not fit, display as much as possible
|
|
return (max + min) / 2;
|
|
}
|
|
if (min >= (coordinate - padding - halfSize)) {
|
|
coordinate = min + padding + halfSize;
|
|
}
|
|
if (max <= (coordinate + padding + halfSize)) {
|
|
coordinate = max - padding - halfSize;
|
|
}
|
|
return coordinate;
|
|
}
|
|
|
|
function getArrowHeads(line) {
|
|
const options = line.options;
|
|
const arrowStartOpts = options.arrowHeads && options.arrowHeads.start;
|
|
const arrowEndOpts = options.arrowHeads && options.arrowHeads.end;
|
|
return {
|
|
startOpts: arrowStartOpts,
|
|
endOpts: arrowEndOpts,
|
|
startAdjust: getLineAdjust(line, arrowStartOpts),
|
|
endAdjust: getLineAdjust(line, arrowEndOpts)
|
|
};
|
|
}
|
|
|
|
function getLineAdjust(line, arrowOpts) {
|
|
if (!arrowOpts || !arrowOpts.display) {
|
|
return 0;
|
|
}
|
|
const {length, width} = arrowOpts;
|
|
const adjust = line.options.borderWidth / 2;
|
|
const p1 = {x: length, y: width + adjust};
|
|
const p2 = {x: 0, y: adjust};
|
|
return Math.abs(interpolateX(0, p1, p2));
|
|
}
|
|
|
|
function drawArrowHead(ctx, offset, adjust, arrowOpts) {
|
|
if (!arrowOpts || !arrowOpts.display) {
|
|
return;
|
|
}
|
|
const {length, width, fill, backgroundColor, borderColor} = arrowOpts;
|
|
const arrowOffsetX = Math.abs(offset - length) + adjust;
|
|
ctx.beginPath();
|
|
setShadowStyle(ctx, arrowOpts);
|
|
setBorderStyle(ctx, arrowOpts);
|
|
ctx.moveTo(arrowOffsetX, -width);
|
|
ctx.lineTo(offset + adjust, 0);
|
|
ctx.lineTo(arrowOffsetX, width);
|
|
if (fill === true) {
|
|
ctx.fillStyle = backgroundColor || borderColor;
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.shadowColor = 'transparent';
|
|
} else {
|
|
ctx.shadowColor = arrowOpts.borderShadowColor;
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
function getControlPoint(properties, options, distance) {
|
|
const {x, y, x2, y2, centerX, centerY} = properties;
|
|
const angle = Math.atan2(y2 - y, x2 - x);
|
|
const cp = toPosition(options.controlPoint, 0);
|
|
const point = {
|
|
x: centerX + getSize(distance, cp.x, false),
|
|
y: centerY + getSize(distance, cp.y, false)
|
|
};
|
|
return rotated(point, {x: centerX, y: centerY}, angle);
|
|
}
|
|
|
|
function drawArrowHeadOnCurve(ctx, {x, y}, {angle, adjust}, arrowOpts) {
|
|
if (!arrowOpts || !arrowOpts.display) {
|
|
return;
|
|
}
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(angle);
|
|
drawArrowHead(ctx, 0, -adjust, arrowOpts);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawCurve(ctx, element, cp, length) {
|
|
const {x, y, x2, y2, options} = element;
|
|
const {startOpts, endOpts, startAdjust, endAdjust} = getArrowHeads(element);
|
|
const p1 = {x, y};
|
|
const p2 = {x: x2, y: y2};
|
|
const startAngle = angleInCurve(p1, cp, p2, 0);
|
|
const endAngle = angleInCurve(p1, cp, p2, 1) - helpers.PI;
|
|
const ps = pointInCurve(p1, cp, p2, startAdjust / length);
|
|
const pe = pointInCurve(p1, cp, p2, 1 - endAdjust / length);
|
|
|
|
const path = new Path2D();
|
|
ctx.beginPath();
|
|
path.moveTo(ps.x, ps.y);
|
|
path.quadraticCurveTo(cp.x, cp.y, pe.x, pe.y);
|
|
ctx.shadowColor = options.borderShadowColor;
|
|
ctx.stroke(path);
|
|
element.path = path;
|
|
element.ctx = ctx;
|
|
drawArrowHeadOnCurve(ctx, ps, {angle: startAngle, adjust: startAdjust}, startOpts);
|
|
drawArrowHeadOnCurve(ctx, pe, {angle: endAngle, adjust: endAdjust}, endOpts);
|
|
}
|
|
|
|
class EllipseAnnotation extends chart_js.Element {
|
|
|
|
inRange(mouseX, mouseY, axis, useFinalPosition) {
|
|
const rotation = this.options.rotation;
|
|
const hitSize = (this.options.borderWidth + this.options.hitTolerance) / 2;
|
|
if (axis !== 'x' && axis !== 'y') {
|
|
return pointInEllipse({x: mouseX, y: mouseY}, this.getProps(['width', 'height', 'centerX', 'centerY'], useFinalPosition), rotation, hitSize);
|
|
}
|
|
const {x, y, x2, y2} = this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition);
|
|
const limit = axis === 'y' ? {start: y, end: y2} : {start: x, end: x2};
|
|
const rotatedPoint = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), helpers.toRadians(-rotation));
|
|
return rotatedPoint[axis] >= limit.start - hitSize - EPSILON && rotatedPoint[axis] <= limit.end + hitSize + EPSILON;
|
|
}
|
|
|
|
getCenterPoint(useFinalPosition) {
|
|
return getElementCenterPoint(this, useFinalPosition);
|
|
}
|
|
|
|
draw(ctx) {
|
|
const {width, height, centerX, centerY, options} = this;
|
|
ctx.save();
|
|
translate(ctx, this.getCenterPoint(), options.rotation);
|
|
setShadowStyle(ctx, this.options);
|
|
ctx.beginPath();
|
|
ctx.fillStyle = options.backgroundColor;
|
|
const stroke = setBorderStyle(ctx, options);
|
|
ctx.ellipse(centerX, centerY, height / 2, width / 2, helpers.PI / 2, 0, 2 * helpers.PI);
|
|
ctx.fill();
|
|
if (stroke) {
|
|
ctx.shadowColor = options.borderShadowColor;
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
get label() {
|
|
return this.elements && this.elements[0];
|
|
}
|
|
|
|
resolveElementProperties(chart, options) {
|
|
return resolveBoxAndLabelProperties(chart, options);
|
|
}
|
|
|
|
}
|
|
|
|
EllipseAnnotation.id = 'ellipseAnnotation';
|
|
|
|
EllipseAnnotation.defaults = {
|
|
adjustScaleRange: true,
|
|
backgroundShadowColor: 'transparent',
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderShadowColor: 'transparent',
|
|
borderWidth: 1,
|
|
display: true,
|
|
hitTolerance: 0,
|
|
init: undefined,
|
|
label: Object.assign({}, BoxAnnotation.defaults.label),
|
|
rotation: 0,
|
|
shadowBlur: 0,
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
xMax: undefined,
|
|
xMin: undefined,
|
|
xScaleID: undefined,
|
|
yMax: undefined,
|
|
yMin: undefined,
|
|
yScaleID: undefined,
|
|
z: 0
|
|
};
|
|
|
|
EllipseAnnotation.defaultRoutes = {
|
|
borderColor: 'color',
|
|
backgroundColor: 'color'
|
|
};
|
|
|
|
EllipseAnnotation.descriptors = {
|
|
label: {
|
|
_fallback: true
|
|
}
|
|
};
|
|
|
|
function pointInEllipse(p, ellipse, rotation, hitSize) {
|
|
const {width, height, centerX, centerY} = ellipse;
|
|
const xRadius = width / 2;
|
|
const yRadius = height / 2;
|
|
|
|
if (xRadius <= 0 || yRadius <= 0) {
|
|
return false;
|
|
}
|
|
// https://stackoverflow.com/questions/7946187/point-and-ellipse-rotated-position-test-algorithm
|
|
const angle = helpers.toRadians(rotation || 0);
|
|
const cosAngle = Math.cos(angle);
|
|
const sinAngle = Math.sin(angle);
|
|
const a = Math.pow(cosAngle * (p.x - centerX) + sinAngle * (p.y - centerY), 2);
|
|
const b = Math.pow(sinAngle * (p.x - centerX) - cosAngle * (p.y - centerY), 2);
|
|
return (a / Math.pow(xRadius + hitSize, 2)) + (b / Math.pow(yRadius + hitSize, 2)) <= 1.0001;
|
|
}
|
|
|
|
class PointAnnotation extends chart_js.Element {
|
|
|
|
inRange(mouseX, mouseY, axis, useFinalPosition) {
|
|
const {x, y, x2, y2, width} = this.getProps(['x', 'y', 'x2', 'y2', 'width'], useFinalPosition);
|
|
const hitSize = (this.options.borderWidth + this.options.hitTolerance) / 2;
|
|
if (axis !== 'x' && axis !== 'y') {
|
|
return inPointRange({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), width / 2, hitSize);
|
|
}
|
|
const limit = axis === 'y' ? {start: y, end: y2, value: mouseY} : {start: x, end: x2, value: mouseX};
|
|
return inLimit(limit, hitSize);
|
|
}
|
|
|
|
getCenterPoint(useFinalPosition) {
|
|
return getElementCenterPoint(this, useFinalPosition);
|
|
}
|
|
|
|
draw(ctx) {
|
|
const options = this.options;
|
|
const borderWidth = options.borderWidth;
|
|
if (options.radius < 0.1) {
|
|
return;
|
|
}
|
|
ctx.save();
|
|
ctx.fillStyle = options.backgroundColor;
|
|
setShadowStyle(ctx, options);
|
|
const stroke = setBorderStyle(ctx, options);
|
|
drawPoint(ctx, this, this.centerX, this.centerY);
|
|
if (stroke && !isImageOrCanvas(options.pointStyle)) {
|
|
ctx.shadowColor = options.borderShadowColor;
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
options.borderWidth = borderWidth;
|
|
}
|
|
|
|
resolveElementProperties(chart, options) {
|
|
const properties = resolvePointProperties(chart, options);
|
|
properties.initProperties = initAnimationProperties(chart, properties, options);
|
|
return properties;
|
|
}
|
|
}
|
|
|
|
PointAnnotation.id = 'pointAnnotation';
|
|
|
|
PointAnnotation.defaults = {
|
|
adjustScaleRange: true,
|
|
backgroundShadowColor: 'transparent',
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderShadowColor: 'transparent',
|
|
borderWidth: 1,
|
|
display: true,
|
|
hitTolerance: 0,
|
|
init: undefined,
|
|
pointStyle: 'circle',
|
|
radius: 10,
|
|
rotation: 0,
|
|
shadowBlur: 0,
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
xAdjust: 0,
|
|
xMax: undefined,
|
|
xMin: undefined,
|
|
xScaleID: undefined,
|
|
xValue: undefined,
|
|
yAdjust: 0,
|
|
yMax: undefined,
|
|
yMin: undefined,
|
|
yScaleID: undefined,
|
|
yValue: undefined,
|
|
z: 0
|
|
};
|
|
|
|
PointAnnotation.defaultRoutes = {
|
|
borderColor: 'color',
|
|
backgroundColor: 'color'
|
|
};
|
|
|
|
class PolygonAnnotation extends chart_js.Element {
|
|
|
|
inRange(mouseX, mouseY, axis, useFinalPosition) {
|
|
if (axis !== 'x' && axis !== 'y') {
|
|
return this.options.radius >= 0.1 && this.elements.length > 1 && pointIsInPolygon(this.elements, mouseX, mouseY, useFinalPosition);
|
|
}
|
|
const rotatedPoint = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), helpers.toRadians(-this.options.rotation));
|
|
const axisPoints = this.elements.map((point) => axis === 'y' ? point.bY : point.bX);
|
|
const start = Math.min(...axisPoints);
|
|
const end = Math.max(...axisPoints);
|
|
return rotatedPoint[axis] >= start && rotatedPoint[axis] <= end;
|
|
}
|
|
|
|
getCenterPoint(useFinalPosition) {
|
|
return getElementCenterPoint(this, useFinalPosition);
|
|
}
|
|
|
|
draw(ctx) {
|
|
const {elements, options} = this;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.fillStyle = options.backgroundColor;
|
|
setShadowStyle(ctx, options);
|
|
const stroke = setBorderStyle(ctx, options);
|
|
let first = true;
|
|
for (const el of elements) {
|
|
if (first) {
|
|
ctx.moveTo(el.x, el.y);
|
|
first = false;
|
|
} else {
|
|
ctx.lineTo(el.x, el.y);
|
|
}
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// If no border, don't draw it
|
|
if (stroke) {
|
|
ctx.shadowColor = options.borderShadowColor;
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
resolveElementProperties(chart, options) {
|
|
const properties = resolvePointProperties(chart, options);
|
|
const {sides, rotation} = options;
|
|
const elements = [];
|
|
const angle = (2 * helpers.PI) / sides;
|
|
let rad = rotation * helpers.RAD_PER_DEG;
|
|
for (let i = 0; i < sides; i++, rad += angle) {
|
|
const elProps = buildPointElement(properties, options, rad);
|
|
elProps.initProperties = initAnimationProperties(chart, properties, options);
|
|
elements.push(elProps);
|
|
}
|
|
properties.elements = elements;
|
|
return properties;
|
|
}
|
|
}
|
|
|
|
PolygonAnnotation.id = 'polygonAnnotation';
|
|
|
|
PolygonAnnotation.defaults = {
|
|
adjustScaleRange: true,
|
|
backgroundShadowColor: 'transparent',
|
|
borderCapStyle: 'butt',
|
|
borderDash: [],
|
|
borderDashOffset: 0,
|
|
borderJoinStyle: 'miter',
|
|
borderShadowColor: 'transparent',
|
|
borderWidth: 1,
|
|
display: true,
|
|
hitTolerance: 0,
|
|
init: undefined,
|
|
point: {
|
|
radius: 0
|
|
},
|
|
radius: 10,
|
|
rotation: 0,
|
|
shadowBlur: 0,
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
sides: 3,
|
|
xAdjust: 0,
|
|
xMax: undefined,
|
|
xMin: undefined,
|
|
xScaleID: undefined,
|
|
xValue: undefined,
|
|
yAdjust: 0,
|
|
yMax: undefined,
|
|
yMin: undefined,
|
|
yScaleID: undefined,
|
|
yValue: undefined,
|
|
z: 0
|
|
};
|
|
|
|
PolygonAnnotation.defaultRoutes = {
|
|
borderColor: 'color',
|
|
backgroundColor: 'color'
|
|
};
|
|
|
|
function buildPointElement({centerX, centerY}, {radius, borderWidth, hitTolerance}, rad) {
|
|
const hitSize = (borderWidth + hitTolerance) / 2;
|
|
const sin = Math.sin(rad);
|
|
const cos = Math.cos(rad);
|
|
const point = {x: centerX + sin * radius, y: centerY - cos * radius};
|
|
return {
|
|
type: 'point',
|
|
optionScope: 'point',
|
|
properties: {
|
|
x: point.x,
|
|
y: point.y,
|
|
centerX: point.x,
|
|
centerY: point.y,
|
|
bX: centerX + sin * (radius + hitSize),
|
|
bY: centerY - cos * (radius + hitSize)
|
|
}
|
|
};
|
|
}
|
|
|
|
function pointIsInPolygon(points, x, y, useFinalPosition) {
|
|
let isInside = false;
|
|
let A = points[points.length - 1].getProps(['bX', 'bY'], useFinalPosition);
|
|
for (const point of points) {
|
|
const B = point.getProps(['bX', 'bY'], useFinalPosition);
|
|
if ((B.bY > y) !== (A.bY > y) && x < (A.bX - B.bX) * (y - B.bY) / (A.bY - B.bY) + B.bX) {
|
|
isInside = !isInside;
|
|
}
|
|
A = B;
|
|
}
|
|
return isInside;
|
|
}
|
|
|
|
const annotationTypes = {
|
|
box: BoxAnnotation,
|
|
doughnutLabel: DoughnutLabelAnnotation,
|
|
ellipse: EllipseAnnotation,
|
|
label: LabelAnnotation,
|
|
line: LineAnnotation,
|
|
point: PointAnnotation,
|
|
polygon: PolygonAnnotation
|
|
};
|
|
|
|
/**
|
|
* Register fallback for annotation elements
|
|
* For example lineAnnotation options would be looked through:
|
|
* - the annotation object (options.plugins.annotation.annotations[id])
|
|
* - element options (options.elements.lineAnnotation)
|
|
* - element defaults (defaults.elements.lineAnnotation)
|
|
* - annotation plugin defaults (defaults.plugins.annotation, this is what we are registering here)
|
|
*/
|
|
Object.keys(annotationTypes).forEach(key => {
|
|
chart_js.defaults.describe(`elements.${annotationTypes[key].id}`, {
|
|
_fallback: 'plugins.annotation.common'
|
|
});
|
|
});
|
|
|
|
const directUpdater = {
|
|
update: Object.assign
|
|
};
|
|
|
|
const hooks$1 = eventHooks.concat(elementHooks);
|
|
const resolve = (value, optDefs) => helpers.isObject(optDefs) ? resolveObj(value, optDefs) : value;
|
|
|
|
|
|
/**
|
|
* @typedef { import("chart.js").Chart } Chart
|
|
* @typedef { import("chart.js").UpdateMode } UpdateMode
|
|
* @typedef { import('../../types/options').AnnotationPluginOptions } AnnotationPluginOptions
|
|
*/
|
|
|
|
/**
|
|
* @param {string} prop
|
|
* @returns {boolean}
|
|
*/
|
|
const isIndexable = (prop) => prop === 'color' || prop === 'font';
|
|
|
|
/**
|
|
* Resolve the annotation type, checking if is supported.
|
|
* @param {string} [type=line] - annotation type
|
|
* @returns {string} resolved annotation type
|
|
*/
|
|
function resolveType(type = 'line') {
|
|
if (annotationTypes[type]) {
|
|
return type;
|
|
}
|
|
console.warn(`Unknown annotation type: '${type}', defaulting to 'line'`);
|
|
return 'line';
|
|
}
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {Object} state
|
|
* @param {AnnotationPluginOptions} options
|
|
* @param {UpdateMode} mode
|
|
*/
|
|
function updateElements(chart, state, options, mode) {
|
|
const animations = resolveAnimations(chart, options.animations, mode);
|
|
|
|
const annotations = state.annotations;
|
|
const elements = resyncElements(state.elements, annotations);
|
|
|
|
for (let i = 0; i < annotations.length; i++) {
|
|
const annotationOptions = annotations[i];
|
|
const element = getOrCreateElement(elements, i, annotationOptions.type);
|
|
const resolver = annotationOptions.setContext(getContext(chart, element, elements, annotationOptions));
|
|
const properties = element.resolveElementProperties(chart, resolver);
|
|
|
|
properties.skip = toSkip(properties);
|
|
|
|
if ('elements' in properties) {
|
|
updateSubElements(element, properties.elements, resolver, animations);
|
|
// Remove the sub-element definitions from properties, so the actual elements
|
|
// are not overwritten by their definitions
|
|
delete properties.elements;
|
|
}
|
|
|
|
if (!helpers.defined(element.x)) {
|
|
// If the element is newly created, assing the properties directly - to
|
|
// make them readily awailable to any scriptable options. If we do not do this,
|
|
// the properties retruned by `resolveElementProperties` are available only
|
|
// after options resolution.
|
|
Object.assign(element, properties);
|
|
}
|
|
|
|
Object.assign(element, properties.initProperties);
|
|
properties.options = resolveAnnotationOptions(resolver);
|
|
|
|
animations.update(element, properties);
|
|
}
|
|
}
|
|
|
|
function toSkip(properties) {
|
|
return isNaN(properties.x) || isNaN(properties.y);
|
|
}
|
|
|
|
function resolveAnimations(chart, animOpts, mode) {
|
|
if (mode === 'reset' || mode === 'none' || mode === 'resize') {
|
|
return directUpdater;
|
|
}
|
|
return new chart_js.Animations(chart, animOpts);
|
|
}
|
|
|
|
function updateSubElements(mainElement, elements, resolver, animations) {
|
|
const subElements = mainElement.elements || (mainElement.elements = []);
|
|
subElements.length = elements.length;
|
|
for (let i = 0; i < elements.length; i++) {
|
|
const definition = elements[i];
|
|
const properties = definition.properties;
|
|
const subElement = getOrCreateElement(subElements, i, definition.type, definition.initProperties);
|
|
const subResolver = resolver[definition.optionScope].override(definition);
|
|
properties.options = resolveAnnotationOptions(subResolver);
|
|
animations.update(subElement, properties);
|
|
}
|
|
}
|
|
|
|
function getOrCreateElement(elements, index, type, initProperties) {
|
|
const elementClass = annotationTypes[resolveType(type)];
|
|
let element = elements[index];
|
|
if (!element || !(element instanceof elementClass)) {
|
|
element = elements[index] = new elementClass();
|
|
Object.assign(element, initProperties);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
function resolveAnnotationOptions(resolver) {
|
|
const elementClass = annotationTypes[resolveType(resolver.type)];
|
|
const result = {};
|
|
result.id = resolver.id;
|
|
result.type = resolver.type;
|
|
result.drawTime = resolver.drawTime;
|
|
Object.assign(result,
|
|
resolveObj(resolver, elementClass.defaults),
|
|
resolveObj(resolver, elementClass.defaultRoutes));
|
|
for (const hook of hooks$1) {
|
|
result[hook] = resolver[hook];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function resolveObj(resolver, defs) {
|
|
const result = {};
|
|
for (const prop of Object.keys(defs)) {
|
|
const optDefs = defs[prop];
|
|
const value = resolver[prop];
|
|
if (isIndexable(prop) && helpers.isArray(value)) {
|
|
result[prop] = value.map((item) => resolve(item, optDefs));
|
|
} else {
|
|
result[prop] = resolve(value, optDefs);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function getContext(chart, element, elements, annotation) {
|
|
return element.$context || (element.$context = Object.assign(Object.create(chart.getContext()), {
|
|
element,
|
|
get elements() {
|
|
return elements.filter((el) => el && el.options);
|
|
},
|
|
id: annotation.id,
|
|
type: 'annotation'
|
|
}));
|
|
}
|
|
|
|
function resyncElements(elements, annotations) {
|
|
const count = annotations.length;
|
|
const start = elements.length;
|
|
|
|
if (start < count) {
|
|
const add = count - start;
|
|
elements.splice(start, 0, ...new Array(add));
|
|
} else if (start > count) {
|
|
elements.splice(count, start - count);
|
|
}
|
|
return elements;
|
|
}
|
|
|
|
var version = "3.1.0";
|
|
|
|
const chartStates = new Map();
|
|
const isNotDoughnutLabel = annotation => annotation.type !== 'doughnutLabel';
|
|
const hooks = eventHooks.concat(elementHooks);
|
|
|
|
var Annotation = {
|
|
id: 'annotation',
|
|
|
|
version,
|
|
|
|
beforeRegister() {
|
|
requireVersion('chart.js', '4.0', chart_js.Chart.version);
|
|
},
|
|
|
|
afterRegister() {
|
|
chart_js.Chart.register(annotationTypes);
|
|
},
|
|
|
|
afterUnregister() {
|
|
chart_js.Chart.unregister(annotationTypes);
|
|
},
|
|
|
|
beforeInit(chart) {
|
|
chartStates.set(chart, {
|
|
annotations: [],
|
|
elements: [],
|
|
visibleElements: [],
|
|
listeners: {},
|
|
listened: false,
|
|
moveListened: false,
|
|
hooks: {},
|
|
hooked: false,
|
|
hovered: []
|
|
});
|
|
},
|
|
|
|
beforeUpdate(chart, args, options) {
|
|
const state = chartStates.get(chart);
|
|
const annotations = state.annotations = [];
|
|
|
|
let annotationOptions = options.annotations;
|
|
if (helpers.isObject(annotationOptions)) {
|
|
Object.keys(annotationOptions).forEach(key => {
|
|
const value = annotationOptions[key];
|
|
if (helpers.isObject(value)) {
|
|
value.id = key;
|
|
annotations.push(value);
|
|
}
|
|
});
|
|
} else if (helpers.isArray(annotationOptions)) {
|
|
annotations.push(...annotationOptions);
|
|
}
|
|
verifyScaleOptions(annotations.filter(isNotDoughnutLabel), chart.scales);
|
|
},
|
|
|
|
afterDataLimits(chart, args) {
|
|
const state = chartStates.get(chart);
|
|
adjustScaleRange(chart, args.scale, state.annotations.filter(isNotDoughnutLabel).filter(a => a.display && a.adjustScaleRange));
|
|
},
|
|
|
|
afterUpdate(chart, args, options) {
|
|
const state = chartStates.get(chart);
|
|
updateListeners(chart, state, options);
|
|
updateElements(chart, state, options, args.mode);
|
|
state.visibleElements = state.elements.filter(el => !el.skip && el.options.display);
|
|
updateHooks(chart, state, options);
|
|
},
|
|
|
|
beforeDatasetsDraw(chart, _args, options) {
|
|
draw(chart, 'beforeDatasetsDraw', options.clip);
|
|
},
|
|
|
|
afterDatasetsDraw(chart, _args, options) {
|
|
draw(chart, 'afterDatasetsDraw', options.clip);
|
|
},
|
|
|
|
beforeDatasetDraw(chart, _args, options) {
|
|
draw(chart, _args.index, options.clip);
|
|
},
|
|
|
|
beforeDraw(chart, _args, options) {
|
|
draw(chart, 'beforeDraw', options.clip);
|
|
},
|
|
|
|
afterDraw(chart, _args, options) {
|
|
draw(chart, 'afterDraw', options.clip);
|
|
},
|
|
|
|
beforeEvent(chart, args, options) {
|
|
const state = chartStates.get(chart);
|
|
if (handleEvent(state, args.event, options)) {
|
|
args.changed = true;
|
|
}
|
|
},
|
|
|
|
afterDestroy(chart) {
|
|
chartStates.delete(chart);
|
|
},
|
|
|
|
getAnnotations(chart) {
|
|
const state = chartStates.get(chart);
|
|
return state ? state.elements : [];
|
|
},
|
|
|
|
// only for testing
|
|
_getAnnotationElementsAtEventForMode(visibleElements, event, options) {
|
|
return getElements(visibleElements, event, options);
|
|
},
|
|
|
|
defaults: {
|
|
animations: {
|
|
numbers: {
|
|
properties: ['x', 'y', 'x2', 'y2', 'width', 'height', 'centerX', 'centerY', 'pointX', 'pointY', 'radius'],
|
|
type: 'number'
|
|
},
|
|
colors: {
|
|
properties: ['backgroundColor', 'borderColor'],
|
|
type: 'color'
|
|
}
|
|
},
|
|
clip: true,
|
|
interaction: {
|
|
mode: undefined,
|
|
axis: undefined,
|
|
intersect: undefined
|
|
},
|
|
common: {
|
|
drawTime: 'afterDatasetsDraw',
|
|
init: false,
|
|
label: {
|
|
}
|
|
}
|
|
},
|
|
|
|
descriptors: {
|
|
_indexable: false,
|
|
_scriptable: (prop) => !hooks.includes(prop) && prop !== 'init',
|
|
annotations: {
|
|
_allKeys: false,
|
|
_fallback: (prop, opts) => `elements.${annotationTypes[resolveType(opts.type)].id}`
|
|
},
|
|
interaction: {
|
|
_fallback: true
|
|
},
|
|
common: {
|
|
label: {
|
|
_indexable: isIndexable,
|
|
_fallback: true
|
|
},
|
|
_indexable: isIndexable
|
|
}
|
|
},
|
|
|
|
additionalOptionScopes: ['']
|
|
};
|
|
|
|
function draw(chart, caller, clip) {
|
|
const {ctx, chartArea} = chart;
|
|
const state = chartStates.get(chart);
|
|
|
|
if (clip) {
|
|
helpers.clipArea(ctx, chartArea);
|
|
}
|
|
|
|
const drawableElements = getDrawableElements(state.visibleElements, caller).sort((a, b) => a.element.options.z - b.element.options.z);
|
|
for (const item of drawableElements) {
|
|
drawElement(ctx, chartArea, state, item);
|
|
}
|
|
|
|
if (clip) {
|
|
helpers.unclipArea(ctx);
|
|
}
|
|
}
|
|
|
|
function getDrawableElements(elements, caller) {
|
|
const drawableElements = [];
|
|
for (const el of elements) {
|
|
if (el.options.drawTime === caller) {
|
|
drawableElements.push({element: el, main: true});
|
|
}
|
|
if (el.elements && el.elements.length) {
|
|
for (const sub of el.elements) {
|
|
if (sub.options.display && sub.options.drawTime === caller) {
|
|
drawableElements.push({element: sub});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return drawableElements;
|
|
}
|
|
|
|
function drawElement(ctx, chartArea, state, item) {
|
|
const el = item.element;
|
|
if (item.main) {
|
|
invokeHook(state, el, 'beforeDraw');
|
|
el.draw(ctx, chartArea);
|
|
invokeHook(state, el, 'afterDraw');
|
|
} else {
|
|
el.draw(ctx, chartArea);
|
|
}
|
|
}
|
|
|
|
chart_js.Chart.register(Annotation);
|
|
|
|
return Annotation;
|
|
|
|
}));
|