diff --git a/docs/guide/types/_commonInnerLabel.md b/docs/guide/types/_commonInnerLabel.md index b193ba977..746b448fa 100644 --- a/docs/guide/types/_commonInnerLabel.md +++ b/docs/guide/types/_commonInnerLabel.md @@ -19,6 +19,7 @@ All of these options can be [Scriptable](../options.md#scriptable-options) | `textAlign` | `string` | `'start'` | Text alignment of label content when there's more than one line. Possible options are: `'left'`, `'start'`, `'center'`, `'end'`, `'right'`. | `textStrokeColor` | [`Color`](../options.md#color) | `undefined` | The color of the stroke around the text. | `textStrokeWidth` | `number` | `0` | Stroke width around the text. +| `textWrap` | `boolean` | `false` | If `true` and the content label overflows the size of the box, the label has been splitted in multiple lines to be drawn in the box. This options is applicable only on `'box'` annotation. | `width` | `number`\|`string` | `undefined` | Overrides the width of the image or canvas element. Could be set in pixel by a number, or in percentage of current width of image or canvas element by a string. If undefined, uses the width of the image or canvas element. It is used only when the content is an image or canvas element. | `xAdjust` | `number` | `0` | Adjustment along x-axis (left-right) of label relative to computed position. Negative values move the label left, positive right. | `yAdjust` | `number` | `0` | Adjustment along y-axis (top-bottom) of label relative to computed position. Negative values move the label up, positive down. diff --git a/src/helpers/helpers.canvas.js b/src/helpers/helpers.canvas.js index 436902b8b..f0a1cfaa8 100644 --- a/src/helpers/helpers.canvas.js +++ b/src/helpers/helpers.canvas.js @@ -1,13 +1,19 @@ -import {addRoundedRectPath, isArray, isNumber, toFont, toTRBLCorners, toRadians, PI, TAU, HALF_PI, QUARTER_PI, TWO_THIRDS_PI, RAD_PER_DEG} from 'chart.js/helpers'; +import {addRoundedRectPath, isArray, isNumber, toFont, toPadding, 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} from './helpers.options'; +const BLANK = ' '; 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 getContent = (content, wrappedText) => wrappedText + ? wrappedText + : isArray(content) + ? content + : [content]; /** * @typedef { import('chart.js').Point } Point @@ -94,6 +100,23 @@ export function measureLabelSize(ctx, options) { return widthCache.get(mapKey); } +/** + * @param {CanvasRenderingContext2D} ctx + * @param {{width: number}} properties + * @param {CoreLabelOptions} options + * @returns {{width: number, height: number}} + */ +export function measureWrappedLabelSize(ctx, properties, options) { + const wrappedOptions = wrapLabel(ctx, properties, options); + const result = measureLabelSize(ctx, { + content: wrappedOptions.content, + font: wrappedOptions.font, + textStrokeWidth: options.textStrokeWidth + }); + result.wrappedOptions = wrappedOptions; + return result; +} + /** * @param {CanvasRenderingContext2D} ctx * @param {{x: number, y: number, width: number, height: number}} rect @@ -121,7 +144,7 @@ export function drawBox(ctx, rect, options) { /** * @param {CanvasRenderingContext2D} ctx - * @param {{x: number, y: number, width: number, height: number}} rect + * @param {{x: number, y: number, width: number, height: number, _wrappedOptions: object|undefined}} rect * @param {CoreLabelOptions} options */ export function drawLabel(ctx, rect, options) { @@ -133,10 +156,11 @@ export function drawLabel(ctx, rect, options) { ctx.restore(); return; } - const labels = isArray(content) ? content : [content]; - const optFont = options.font; + const wrapOpt = rect._wrappedOptions || {}; + const labels = getContent(content, wrapOpt.content); + const optFont = wrapOpt.font || options.font; const fonts = isArray(optFont) ? optFont.map(f => toFont(f)) : [toFont(optFont)]; - const optColor = options.color; + const optColor = wrapOpt.color || options.color; const colors = isArray(optColor) ? optColor : [optColor]; const x = calculateTextAlignment(rect, options); const y = rect.y + options.textStrokeWidth / 2; @@ -329,3 +353,68 @@ function getOpacity(value, elementValue) { const opacity = isNumber(value) ? value : elementValue; return isNumber(opacity) ? clamp(opacity, 0, 1) : 1; } + +function wrapLabel(ctx, properties, options) { + const padding = toPadding(options.padding); + const maxWidth = properties.width - padding.left - padding.right - options.borderWidth; + const content = options.content; + const text = isArray(content) ? content : [content]; + const optFont = options.font; + const fonts = isArray(optFont) ? optFont : [optFont]; + const optColor = options.color; + const colors = isArray(optColor) ? optColor : [optColor]; + ctx.save(); + const result = scanLabelLines(ctx, text, maxWidth, {fonts, colors}); + ctx.restore(); + return result; +} + +function scanLabelLines(ctx, text, maxWidth, {fonts, colors}) { + const result = { + content: [], + font: [], + color: [] + }; + text.forEach(function(line, index) { + const normLine = line + ''; + const c = colors[Math.min(index, colors.length - 1)]; + const f = fonts[Math.min(index, fonts.length - 1)]; + const font = toFont(f); + ctx.font = font.string; + const width = ctx.measureText(normLine).width; + if (maxWidth >= width || !normLine.includes(BLANK)) { + result.content.push(normLine); + result.font.push(f); + result.color.push(c); + } else { + const wrappedLine = splitLabel(ctx, normLine, maxWidth); + result.content.push(...wrappedLine); + result.font.push(...wrappedLine.map(() => f)); + result.color.push(...wrappedLine.map(() => c)); + } + }); + return result; +} + +function splitLabel(ctx, text, maxWidth) { + const sepaWidth = ctx.measureText(BLANK).width; + const result = []; + const words = text.split(BLANK); + let temp = ''; + let current = 0; + for (const w of words) { + const wWidth = ctx.measureText(w).width; + if ((current + wWidth + sepaWidth) <= maxWidth) { + temp += w + BLANK; + current += wWidth + sepaWidth; + } else { + result.push(temp.trim()); + temp = w + BLANK; + current = wWidth + sepaWidth; + } + } + if (temp.length) { + result.push(temp.trim()); + } + return result; +} diff --git a/src/helpers/helpers.chart.js b/src/helpers/helpers.chart.js index 76ab085c6..3ae792d09 100644 --- a/src/helpers/helpers.chart.js +++ b/src/helpers/helpers.chart.js @@ -1,11 +1,12 @@ import {isFinite, toPadding} from 'chart.js/helpers'; -import {measureLabelSize} from './helpers.canvas'; +import {measureLabelSize, measureWrappedLabelSize, isImageOrCanvas} from './helpers.canvas'; import {isBoundToPoint, getRelativePosition, toPosition, initAnimationProperties} from './helpers.options'; 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'} }; +const isTextToWrap = (options) => options.type === 'box' && options.label.textWrap && !isImageOrCanvas(options.content); /** * @typedef { import("chart.js").Chart } Chart @@ -270,7 +271,7 @@ function resolveLabelElementProperties(chart, properties, options) { label.callout.display = false; const position = toPosition(label.position); const padding = toPadding(label.padding); - const labelSize = measureLabelSize(chart.ctx, label); + const labelSize = isTextToWrap(options) ? measureWrappedLabelSize(chart.ctx, properties, label) : 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; @@ -284,7 +285,8 @@ function resolveLabelElementProperties(chart, properties, options) { height, centerX: x + width / 2, centerY: y + height / 2, - rotation: label.rotation + rotation: label.rotation, + _wrappedOptions: labelSize.wrappedOptions }; } diff --git a/src/types/box.js b/src/types/box.js index 66d02ae95..e78299f31 100644 --- a/src/types/box.js +++ b/src/types/box.js @@ -68,6 +68,7 @@ BoxAnnotation.defaults = { textAlign: 'start', textStrokeColor: undefined, textStrokeWidth: 0, + textWrap: false, width: undefined, xAdjust: 0, yAdjust: 0, diff --git a/src/types/label.js b/src/types/label.js index f1c03ce2a..3d3d6104b 100644 --- a/src/types/label.js +++ b/src/types/label.js @@ -244,10 +244,11 @@ function resolveCalloutAutoPosition(element, options) { return result.sort((a, b) => a.distance - b.distance)[0].position; } -function getLabelSize({x, y, width, height, options}) { +function getLabelSize({x, y, width, height, _wrappedOptions, options}) { const hBorderWidth = options.borderWidth / 2; const padding = toPadding(options.padding); return { + _wrappedOptions, x: x + padding.left + hBorderWidth, y: y + padding.top + hBorderWidth, width: width - padding.left - padding.right - options.borderWidth, diff --git a/test/fixtures/box/labelDecoration.js b/test/fixtures/box/labelDecoration.js index 6603baffa..131886fd4 100644 --- a/test/fixtures/box/labelDecoration.js +++ b/test/fixtures/box/labelDecoration.js @@ -1,5 +1,5 @@ module.exports = { - tolerance: 0.0315, + tolerance: 0.0316, config: { type: 'scatter', options: { diff --git a/test/fixtures/box/labelWrap.js b/test/fixtures/box/labelWrap.js new file mode 100644 index 000000000..625ef1d42 --- /dev/null +++ b/test/fixtures/box/labelWrap.js @@ -0,0 +1,68 @@ +module.exports = { + tolerance: 0.0075, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + annotations: { + box1: { + type: 'box', + xMin: -0.5, + xMax: 2, + yMin: 0, + yMax: 5, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: 'This is a long label - '.repeat(4), + textWrap: true, + } + }, + box2: { + type: 'box', + xMin: 2.5, + xMax: 5, + yMin: 0, + yMax: 5, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: ['No wrap!', 'This is a long label - '.repeat(4), 'No wrap!'], + textWrap: true, + } + }, + box3: { + type: 'box', + xMin: 1, + xMax: 5, + yMin: 15, + yMax: 20, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: ['No wrap!', 'Overflow ' + 'This is a long label - '.repeat(3), 'No wrap!'], + textWrap: false, + textAlign: 'center' + } + }, + } + } + } + } + }, + options: { + spriteText: true + } +}; diff --git a/test/fixtures/box/labelWrap.png b/test/fixtures/box/labelWrap.png new file mode 100644 index 000000000..e5d022cf0 Binary files /dev/null and b/test/fixtures/box/labelWrap.png differ diff --git a/test/fixtures/box/labelWrapMultiColors.js b/test/fixtures/box/labelWrapMultiColors.js new file mode 100644 index 000000000..751a0769d --- /dev/null +++ b/test/fixtures/box/labelWrapMultiColors.js @@ -0,0 +1,53 @@ +module.exports = { + tolerance: 0.0137, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + annotations: { + box1: { + type: 'box', + xMin: 2, + xMax: 5, + yMin: 1, + yMax: 6, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: 'This is a long label - '.repeat(4), + textWrap: true, + color: ['green', 'white', 'blue'] + } + }, + box2: { + type: 'box', + xMin: 2, + xMax: 5, + yMin: 10, + yMax: 15, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: ['No wrap!', 'This is a long label - '.repeat(4), 'No wrap!'], + textWrap: true, + color: ['blank', 'green', 'blue'] + } + } + } + } + } + } + } +}; diff --git a/test/fixtures/box/labelWrapMultiColors.png b/test/fixtures/box/labelWrapMultiColors.png new file mode 100644 index 000000000..083de59dd Binary files /dev/null and b/test/fixtures/box/labelWrapMultiColors.png differ diff --git a/test/fixtures/box/labelWrapMultiFonts.js b/test/fixtures/box/labelWrapMultiFonts.js new file mode 100644 index 000000000..80b67a240 --- /dev/null +++ b/test/fixtures/box/labelWrapMultiFonts.js @@ -0,0 +1,53 @@ +module.exports = { + tolerance: 0.0135, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + annotations: { + box1: { + type: 'box', + xMin: 2, + xMax: 5, + yMin: 1, + yMax: 6, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: 'This is a long label - '.repeat(4), + textWrap: true, + font: [{size: 16}, {size: 20}, {size: 24}] + } + }, + box2: { + type: 'box', + xMin: 2, + xMax: 5, + yMin: 10, + yMax: 15, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: ['No wrap!', 'This is a long label - '.repeat(4), 'No wrap!'], + textWrap: true, + font: [{size: 24}, {size: 12}, {size: 16}] + } + } + } + } + } + } + } +}; diff --git a/test/fixtures/box/labelWrapMultiFonts.png b/test/fixtures/box/labelWrapMultiFonts.png new file mode 100644 index 000000000..0ccbaf43a Binary files /dev/null and b/test/fixtures/box/labelWrapMultiFonts.png differ diff --git a/test/fixtures/ellipse/labelDecoration.js b/test/fixtures/ellipse/labelDecoration.js index 19330834b..61351711f 100644 --- a/test/fixtures/ellipse/labelDecoration.js +++ b/test/fixtures/ellipse/labelDecoration.js @@ -1,5 +1,5 @@ module.exports = { - tolerance: 0.0320, + tolerance: 0.0321, config: { type: 'scatter', options: { diff --git a/test/fixtures/ellipse/labelWrap.js b/test/fixtures/ellipse/labelWrap.js new file mode 100644 index 000000000..a21ac4e4e --- /dev/null +++ b/test/fixtures/ellipse/labelWrap.js @@ -0,0 +1,54 @@ +module.exports = { + tolerance: 0.0075, + config: { + type: 'bar', + options: { + scales: { + x: { + display: false, + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + annotations: { + box1: { + type: 'ellipse', + xMin: 2, + xMax: 5, + yMin: 0, + yMax: 5, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: 'No wrap because ellipse - '.repeat(2), + textWrap: true, + } + }, + box2: { + type: 'ellipse', + xMin: 2, + xMax: 5, + yMin: 10, + yMax: 15, + backgroundColor: 'rgba(255, 99, 132, 0.5)', + label: { + display: true, + content: ['No wrap!', 'No wrap because ellipse - '.repeat(2), 'No wrap!'], + textWrap: true, + } + }, + } + } + } + } + }, + options: { + spriteText: true + } +}; diff --git a/test/fixtures/ellipse/labelWrap.png b/test/fixtures/ellipse/labelWrap.png new file mode 100644 index 000000000..e943bc90a Binary files /dev/null and b/test/fixtures/ellipse/labelWrap.png differ diff --git a/types/label.d.ts b/types/label.d.ts index 4c66511f2..0f9d9cba2 100644 --- a/types/label.d.ts +++ b/types/label.d.ts @@ -141,6 +141,7 @@ export interface BoxLabelOptions extends CoreLabelOptions { * @default true */ display?: Scriptable, + textWrap?: Scriptable, rotation?: Scriptable }