Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"@simonbrunel/vuepress-plugin-versions": "^0.2.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"chart.js": "^4.3.0",
"chart.js": "^4.5.1",
"chartjs-test-utils": "^0.5.0",
"concurrently": "^7.6.0",
"cross-env": "^7.0.3",
Expand Down
118 changes: 98 additions & 20 deletions src/helpers/helpers.canvas.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import {addRoundedRectPath, isArray, isNumber, toTRBLCorners, toRadians, PI, TAU, HALF_PI, QUARTER_PI, TWO_THIRDS_PI, RAD_PER_DEG} from 'chart.js/helpers';
import {
addRoundedRectPath,
isArray,
isNumber,
toTRBLCorners,
toRadians,
PI,
TAU,
HALF_PI,
QUARTER_PI,
TWO_THIRDS_PI,
RAD_PER_DEG
} from 'chart.js/helpers';
import {clampAll, clamp} from './helpers.core';
import {calculateTextAlignment, getSize, toFonts} from './helpers.options';

const widthCache = new Map();

const notRadius = (radius) => isNaN(radius) || radius <= 0;
const fontsKey = (fonts) => fonts.reduce(function(prev, item) {
prev += item.string;
return prev;
}, '');

const fontsKey = (fonts) => fonts.reduce((prev, item) => prev + item.string, '');

/**
* @typedef { import('chart.js').Point } Point
Expand All @@ -18,14 +29,15 @@ const fontsKey = (fonts) => fonts.reduce(function(prev, item) {
/**
* Determine if content is an image or a canvas.
* @param {*} content
* @returns boolean|undefined
* @returns {boolean|undefined}
* @todo move this function to chart.js helpers
*/
export function isImageOrCanvas(content) {
if (content && typeof content === 'object') {
const type = content.toString();
return (type === '[object HTMLImageElement]' || type === '[object HTMLCanvasElement]');
}
return;
}

/**
Expand All @@ -42,6 +54,45 @@ export function translate(ctx, {x, y}, rotation) {
}
}

/**
* Compute an anchor point on a rectangle for a given 9-way origin keyword.
* This is useful for choosing a rotation pivot (rotationOrigin) and/or aligning labels.
*
* @param {{x:number, y:number, width:number, height:number}} rect
* @param {'center'|'topLeft'|'top'|'topRight'|'left'|'right'|'bottomLeft'|'bottom'|'bottomRight'} origin
* @returns {{x:number, y:number}}
*/
export function rectAnchorPoint(rect, origin = 'center') {
const x0 = rect.x;
const y0 = rect.y;
const x1 = rect.x + rect.width;
const y1 = rect.y + rect.height;
const xc = (x0 + x1) / 2;
const yc = (y0 + y1) / 2;

switch (origin) {
case 'topLeft':
return {x: x0, y: y0};
case 'top':
return {x: xc, y: y0};
case 'topRight':
return {x: x1, y: y0};
case 'right':
return {x: x1, y: yc};
case 'bottomRight':
return {x: x1, y: y1};
case 'bottom':
return {x: xc, y: y1};
case 'bottomLeft':
return {x: x0, y: y1};
case 'left':
return {x: x0, y: yc};
case 'center':
default:
return {x: xc, y: yc};
}
}

/**
* @param {CanvasRenderingContext2D} ctx
* @param {Object} options
Expand Down Expand Up @@ -77,20 +128,23 @@ export function setShadowStyle(ctx, options) {
*/
export function measureLabelSize(ctx, options) {
const content = options.content;

if (isImageOrCanvas(content)) {
const size = {
return {
width: getSize(content.width, options.width),
height: getSize(content.height, options.height)
};
return size;
}

const fonts = toFonts(options);
const strokeWidth = options.textStrokeWidth;
const lines = 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);
}

Expand All @@ -101,21 +155,26 @@ export function measureLabelSize(ctx, options) {
*/
export 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();
addRoundedRectPath(ctx, {
x, y, w: width, h: height,
radius: clampAll(toTRBLCorners(options.borderRadius), 0, Math.min(width, height) / 2)
});
ctx.closePath();
ctx.fill();

if (stroke) {
ctx.shadowColor = options.borderShadowColor;
ctx.stroke();
}

ctx.restore();
}

Expand All @@ -127,25 +186,32 @@ export function drawBox(ctx, rect, options) {
*/
export 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 = isArray(content) ? content : [content];
const fonts = toFonts(options, fitRatio);

const optColor = options.color;
const colors = 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();
}
Expand Down Expand Up @@ -181,22 +247,25 @@ export function drawPoint(ctx, element, x, y) {
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, TAU);
ctx.closePath();
break;

case 'triangle':
ctx.moveTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius);
rad += TWO_THIRDS_PI;
Expand All @@ -205,14 +274,9 @@ function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) {
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
// See Chart.js notes in original code.
cornerRadius = radius * 0.516;
size = radius - cornerRadius;
xOffset = Math.cos(rad + QUARTER_PI) * size;
Expand All @@ -223,14 +287,15 @@ function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) {
ctx.arc(x - yOffset, y + xOffset, cornerRadius, rad + HALF_PI, rad + 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 += QUARTER_PI;
/* falls through */
// falls through
case 'rectRot':
xOffset = Math.cos(rad) * radius;
yOffset = Math.sin(rad) * radius;
Expand All @@ -240,9 +305,10 @@ function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) {
ctx.lineTo(x - yOffset, y + xOffset);
ctx.closePath();
break;

case 'crossRot':
rad += QUARTER_PI;
/* falls through */
// falls through
case 'cross':
xOffset = Math.cos(rad) * radius;
yOffset = Math.sin(rad) * radius;
Expand All @@ -251,6 +317,7 @@ function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) {
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;
Expand All @@ -266,12 +333,14 @@ function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) {
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);
Expand All @@ -283,39 +352,48 @@ function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) {

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) {
labels.forEach((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) {

labels.forEach((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;
Expand Down
9 changes: 1 addition & 8 deletions src/types/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,7 @@ export {
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 => {
defaults.describe(`elements.${annotationTypes[key].id}`, {
_fallback: 'plugins.annotation.common'
Expand Down
Loading