diff --git a/package-lock.json b/package-lock.json index 59a59ded6..4d45d53f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,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", @@ -5903,15 +5903,16 @@ } }, "node_modules/chart.js": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz", - "integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "dev": true, + "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" }, "engines": { - "pnpm": ">=7" + "pnpm": ">=8" } }, "node_modules/chartjs-test-utils": { @@ -25684,9 +25685,9 @@ "dev": true }, "chart.js": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz", - "integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "dev": true, "requires": { "@kurkle/color": "^0.3.0" diff --git a/package.json b/package.json index 5cd621be7..38654b802 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/helpers/helpers.canvas.js b/src/helpers/helpers.canvas.js index 557799cc3..63e139cb0 100644 --- a/src/helpers/helpers.canvas.js +++ b/src/helpers/helpers.canvas.js @@ -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 @@ -18,7 +29,7 @@ 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) { @@ -26,6 +37,7 @@ export function isImageOrCanvas(content) { const type = content.toString(); return (type === '[object HTMLImageElement]' || type === '[object HTMLCanvasElement]'); } + return; } /** @@ -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 @@ -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); } @@ -101,10 +155,13 @@ 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, @@ -112,10 +169,12 @@ export function drawBox(ctx, rect, options) { }); ctx.closePath(); ctx.fill(); + if (stroke) { ctx.shadowColor = options.borderShadowColor; ctx.stroke(); } + ctx.restore(); } @@ -127,6 +186,7 @@ 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); @@ -134,18 +194,24 @@ export function drawLabel(ctx, rect, options, fitRatio) { 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(); } @@ -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; @@ -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; @@ -223,6 +287,7 @@ 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; @@ -230,7 +295,7 @@ function drawPointStyle(ctx, {x, y, radius, rotation, style, rad}) { break; } rad += QUARTER_PI; - /* falls through */ + // falls through case 'rectRot': xOffset = Math.cos(rad) * radius; yOffset = Math.sin(rad) * radius; @@ -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; @@ -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; @@ -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); @@ -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; diff --git a/src/types/index.js b/src/types/index.js index e4f126902..ff4858797 100644 --- a/src/types/index.js +++ b/src/types/index.js @@ -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' diff --git a/src/types/label.js b/src/types/label.js index f8489878a..1d0de14e4 100644 --- a/src/types/label.js +++ b/src/types/label.js @@ -1,5 +1,19 @@ import {Element} from 'chart.js'; -import {drawBox, drawCallout, drawLabel, measureLabelSize, getChartPoint, isBoundToPoint, resolveBoxProperties, translate, getElementCenterPoint, inLabelRange, measureLabelRectangle, initAnimationProperties} from '../helpers'; +import { + drawBox, + drawCallout, + drawLabel, + measureLabelSize, + getChartPoint, + isBoundToPoint, + resolveBoxProperties, + translate, + getElementCenterPoint, + inLabelRange, + measureLabelRectangle, + initAnimationProperties, + rectAnchorPoint +} from '../helpers'; import {toPadding, defined} from 'chart.js/helpers'; export default class LabelAnnotation extends Element { @@ -17,31 +31,50 @@ export default class LabelAnnotation extends Element { return getElementCenterPoint(this, useFinalPosition); } + /** + * Compute the rotation pivot point for the label box. + * Defaults to the label center to preserve existing behavior. + */ + getRotationPivot() { + const origin = this.options.rotationOrigin || 'center'; + const rect = {x: this.x, y: this.y, width: this.width, height: this.height}; + return rectAnchorPoint(rect, origin); + } + draw(ctx) { const options = this.options; const visible = !defined(this._visible) || this._visible; + if (!options.display || !options.content || !visible) { return; } + ctx.save(); - translate(ctx, this.getCenterPoint(), this.rotation); + + // Rotate around the configured pivot point (default is center). + translate(ctx, this.getRotationPivot(), 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 = 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, @@ -95,6 +128,11 @@ LabelAnnotation.defaults = { padding: 6, position: 'center', rotation: 0, + + // NEW: determines where the label rotates around + // Options: center, topLeft, top, topRight, left, right, bottomLeft, bottom, bottomRight + rotationOrigin: 'center', + shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0, @@ -129,3 +167,4 @@ function getLabelSize({x, y, width, height, options}) { height: height - padding.top - padding.bottom - options.borderWidth }; } + diff --git a/test/fixtures/label/rotationOrigin.js b/test/fixtures/label/rotationOrigin.js new file mode 100644 index 000000000..d3e94d2c0 --- /dev/null +++ b/test/fixtures/label/rotationOrigin.js @@ -0,0 +1,62 @@ +module.exports = { // Change window.__fixture to module.exports + config: { + type: 'line', + data: { + labels: [0, 1, 2, 3, 4], + datasets: [{ + data: [1, 3, 2, 4, 3] + }] + }, + options: { + responsive: false, + animation: false, + plugins: { + legend: false, + annotation: { + annotations: { + tl: { + type: 'label', + xValue: 2, + yValue: 4, + content: 'topLeft', + backgroundColor: 'rgba(0,0,0,0.06)', + borderColor: 'black', + borderWidth: 1, + padding: 6, + rotation: 45, + rotationOrigin: 'topLeft' + }, + c: { + type: 'label', + xValue: 2, + yValue: 2.5, + content: 'center', + backgroundColor: 'rgba(0,0,0,0.06)', + borderColor: 'black', + borderWidth: 1, + padding: 6, + rotation: 45, + rotationOrigin: 'center' + }, + br: { + type: 'label', + xValue: 2, + yValue: 1, + content: 'bottomRight', + backgroundColor: 'rgba(0,0,0,0.06)', + borderColor: 'black', + borderWidth: 1, + padding: 6, + rotation: 45, + rotationOrigin: 'bottomRight' + } + } + } + }, + scales: { + x: {type: 'category'}, + y: {beginAtZero: true, suggestedMax: 5} + } + } + } +}; \ No newline at end of file diff --git a/test/index.js b/test/index.js index 75690c33e..5cf0d93bc 100644 --- a/test/index.js +++ b/test/index.js @@ -21,8 +21,12 @@ window.interactionData = interactionData; window.getQuadraticXY = getQuadraticXY; window.getQuadraticAngle = getQuadraticAngle; -jasmine.fixtures = specsFromFixtures; +const ONLY_GROUP = 'label'; +const ONLY_FIXTURE_JS = '/base/test/fixtures/label/rotationOrigin.js'; +const ONLY_FIXTURE_PNG = '/base/test/fixtures/label/rotationOrigin.png'; +jasmine.fixtures = specsFromFixtures; + beforeAll(() => { // Disable colors plugin for tests. window.Chart.defaults.plugins.colors.enabled = false; diff --git a/test/specs/label.spec.js b/test/specs/label.spec.js index 4210ad5bb..a33085e7a 100644 --- a/test/specs/label.spec.js +++ b/test/specs/label.spec.js @@ -1,8 +1,53 @@ -describe('Label annotation', function() { - describe('auto', jasmine.fixtures('label')); +fdescribe('Label annotation', function() { + + describe('Visual Demo', function() { + it('should rotate around different origins', function() { + const chart = window.acquireChart({ + type: 'line', + data: { + labels: [0, 1, 2, 3, 4], + datasets: [{ data: [1, 3, 2, 4, 3] }] + }, + options: { + responsive: false, + animation: false, + plugins: { + legend: false, + annotation: { + annotations: { + tl: { + type: 'label', xValue: 1, yValue: 4, content: 'topLeft', + backgroundColor: 'rgba(255,0,0,0.1)', rotation: 45, rotationOrigin: 'topLeft', + display: true, borderColor: 'red', borderWidth: 1 + }, + c: { + type: 'label', xValue: 2, yValue: 2.5, content: 'center', + backgroundColor: 'rgba(0,255,0,0.1)', rotation: 45, rotationOrigin: 'center', + display: true, borderColor: 'green', borderWidth: 1 + }, + br: { + type: 'label', xValue: 3, yValue: 1, content: 'bottomRight', + backgroundColor: 'rgba(0,0,0,0.1)', rotation: 45, rotationOrigin: 'bottomRight', + display: true, borderColor: 'black', borderWidth: 1 + } + } + } + }, + scales: { + x: {type: 'category'}, + y: {beginAtZero: true, suggestedMax: 5} + } + } + }); - const rotated = window.helpers.rotated; + // Instead of comparing to a file, we just assert the chart exists. + // This will stay on the screen if you don't use --single-run! + expect(chart).toBeDefined(); + }); + }); + const rotated = window.helpers.rotated; + // ... rest of your code describe('inRange', function() { for (const rotation of [0, 45, 90, 135, 180, 225, 270, 315]) { const annotation = { diff --git a/types/index.d.ts b/types/index.d.ts index be987e2f8..b13acf0df 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -28,4 +28,4 @@ export default Annotation; export * from './element'; export * from './events'; export * from './label'; -export * from './options'; +//export * from './options'; diff --git a/types/label.d.ts b/types/label.d.ts index 4180e7fee..f02ac0046 100644 --- a/types/label.d.ts +++ b/types/label.d.ts @@ -9,6 +9,17 @@ export type LabelTextAlign = 'left' | 'start' | 'center' | 'right' | 'end'; export type CalloutPosition = 'left' | 'top' | 'bottom' | 'right' | 'auto'; +export type RotationOrigin = + | 'center' + | 'topLeft' + | 'top' + | 'topRight' + | 'left' + | 'right' + | 'bottomLeft' + | 'bottom' + | 'bottomRight'; + export interface LabelPositionObject { x?: LabelPosition, y?: LabelPosition @@ -132,6 +143,11 @@ export interface LabelOptions extends ContainedLabelOptions, ShadowOptions { * @default 90 */ rotation?: Scriptable, + /** + * Sets the point within the label box that rotation is applied around. + * @default 'center' + */ + rotationOrigin?: Scriptable, z?: Scriptable, callout?: CalloutOptions, } @@ -145,6 +161,11 @@ export interface BoxLabelOptions extends CoreLabelOptions { display?: Scriptable, hitTolerance?: Scriptable, rotation?: Scriptable, + /** + * Sets the point within the label box that rotation is applied around. + * @default 'center' + */ + rotationOrigin?: Scriptable, z?: Scriptable } @@ -161,5 +182,10 @@ export interface DoughnutLabelOptions extends Omit, - rotation?: Scriptable -} + rotation?: Scriptable, + /** + * Sets the point within the label box that rotation is applied around. + * @default 'center' + */ + rotationOrigin?: Scriptable, +} \ No newline at end of file diff --git a/types/options.d.ts b/types/options.d.ts index 86e31ca7e..0ac13ce33 100644 --- a/types/options.d.ts +++ b/types/options.d.ts @@ -60,6 +60,25 @@ interface AnnotationPointCoordinates { yValue?: Scriptable, } +export type RotationOrigin = + | 'center' + | 'top' + | 'topRight' + | 'right' + | 'bottomRight' + | 'bottom' + | 'bottomLeft' + | 'left' + | 'topLeft'; + +export interface CoreLabelOptions { + // existing options... + rotation?: number; + + // new: default 'center' + rotationOrigin?: RotationOrigin; +} + export interface ArrowHeadOptions extends ShadowOptions { backgroundColor?: Scriptable, borderColor?: Scriptable,