diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 1133742bd..bdbc82804 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -166,6 +166,7 @@ module.exports = { 'label/image', 'label/innerChart', 'label/lowerUpper', + 'label/fontsColors', 'label/autoscaling' ] }, diff --git a/docs/guide/types/_commonInnerLabel.md b/docs/guide/types/_commonInnerLabel.md index 102dbaf33..b193ba977 100644 --- a/docs/guide/types/_commonInnerLabel.md +++ b/docs/guide/types/_commonInnerLabel.md @@ -6,11 +6,11 @@ All of these options can be [Scriptable](../options.md#scriptable-options) | Name | Type | Default | Notes | ---- | ---- | :----: | ---- -| `color` | [`Color`](../options.md#color) | `'black'` | Text color. +| [`color`](#fonts-and-colors) | [`Color`\|`Color[]`](../options#color) | `'black'` | Text color. | `content` | `string`\|`string[]`\|[`Image`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image)\|[`HTMLCanvasElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement) | `null` | The content to show in the label. | `display` | `boolean` | `false` | Whether or not the label is shown. -| `drawTime` | `string` | `options.drawTime` | See [drawTime](../options.md#draw-time). Defaults to the annotation draw time if unset -| `font` | [`Font`](../options.md#font) | `{ weight: 'bold' }` | Label font +| `drawTime` | `string` | `options.drawTime` | See [drawTime](../options#draw-time). Defaults to the annotation draw time if unset +| [`font`](#fonts-and-colors) | [`Font`\|`Font[]`](../options#font) | `{ weight: 'bold' }` | Label font | `height` | `number`\|`string` | `undefined` | Overrides the height of the image or canvas element. Could be set in pixel by a number, or in percentage of current height of image or canvas element by a string. If undefined, uses the height of the image or canvas element. It is used only when the content is an image or canvas element. | `opacity` | `number` | `undefined` | Overrides the opacity of the image or canvas element. Could be set a number in the range 0.0 to 1.0, inclusive. If undefined, uses the opacity of the image or canvas element. It is used only when the content is an image or canvas element. | `padding` | [`Padding`](../options.md#padding) | `6` | The padding to add around the text label. @@ -34,3 +34,7 @@ A position can be set in 2 different values types: If this value is a string (possible options are `'start'`, `'center'`, `'end'` or a string in percentage format), it is applied to vertical and horizontal position in the annotation. If this value is an object, the `x` property defines the horizontal alignment in the annotation. Similarly, the `y` property defines the vertical alignment in the annotation. Possible options for both properties are `'start'`, `'center'`, `'end'`, a string in percentage format. Omitted property have value of the default, `'center'`. + +### Fonts and colors + +When the label to draw has multiple lines, you can use different font and color for each line of the label. This is enabled configuring an array of fonts or colors for those options. When the lines are more than the configured fonts of colors, the last configuration of those options is used for all remaining lines. diff --git a/docs/guide/types/label.md b/docs/guide/types/label.md index d33cbea3a..ae21635a5 100644 --- a/docs/guide/types/label.md +++ b/docs/guide/types/label.md @@ -60,9 +60,9 @@ The following options are available for label annotations. | [`borderRadius`](#borderradius) | `number` \| `object` | Yes | `0` | [`borderWidth`](#styling) | `number`| Yes | `0` | [`callout`](#callout) | `object` | Yes | -| [`color`](#styling) | [`Color`](../options.md#color) | Yes | `'black'` +| [`color`](#styling) | [`Color`\|`Color[]`](../options#color) | Yes | `'black'` | [`content`](#general) | `string`\|`string[]`\|[`Image`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image)\|[`HTMLCanvasElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement) | Yes | `null` -| [`font`](#styling) | [`Font`](../options.md#font) | Yes | `{}` +| [`font`](#styling) | [`Font`\|`Font[]`](../options#font) | Yes | `{}` | [`height`](#general) | `number`\|`string` | Yes | `undefined` | [`opacity`](#styling) | `number` | Yes | `undefined` | [`padding`](#general) | [`Padding`](../options.md#padding) | Yes | `6` @@ -122,8 +122,8 @@ The 4 coordinates, xMin, xMax, yMin, yMax are optional. If not specified, the bo | `borderJoinStyle` | Border line join style. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin). | `borderShadowColor` | The color of the border shadow. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowColor). | `borderWidth` | Stroke width (in pixels). -| `color` | Text color. -| `font` | Text font. +| `color` | Text color. When the label to draw has multiple lines, you can use different color for each line of the label. This is enabled configuring an array of colors. When the lines are more than the configured colors, the last configuration of this option is used for all remaining lines. +| `font` | Text font. When the label to draw has multiple lines, you can use different font for each line of the label. This is enabled configuring an array of fonts. When the lines are more than the configured fonts, the last configuration of this option is used for all remaining lines. | `opacity` | Overrides the opacity of the image or canvas element. Could be set a number in the range 0.0 to 1.0, inclusive. If undefined, uses the opacity of the image or canvas element. It is used only when the content is an image or canvas element. | `shadowBlur` | The amount of blur applied to shadow of the box where the label is located. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowBlur). | `shadowOffsetX` | The distance that shadow, of the box where the label is located, will be offset horizontally. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowOffsetX). diff --git a/docs/guide/types/line.md b/docs/guide/types/line.md index 41ba893b9..7bd13d26e 100644 --- a/docs/guide/types/line.md +++ b/docs/guide/types/line.md @@ -131,11 +131,11 @@ All of these options can be [Scriptable](../options.md#scriptable-options) | `borderShadowColor` | [`Color`](../options.md#color) | `'transparent'` | The color of border shadow of the box where the label is located. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowColor). | `borderWidth` | `number` | `0` | The border line width (in pixels). | [`callout`](#callout) | `object` | | Can connect the label to the line. See [callout](#callout). -| `color` | [`Color`](../options.md#color) | `'#fff'` | Text color. +| [`color`](#fonts-and-colors) | [`Color`\|`Color[]`](../options#color) | `'#fff'` | Text color. | `content` | `string`\|`string[]`\|[`Image`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image)\|[`HTMLCanvasElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement) | `null` | The content to show in the label. | `display` | `boolean` | `false` | Whether or not the label is shown. -| `drawTime` | `string` | `options.drawTime` | See [drawTime](../options.md#draw-time). Defaults to the line annotation draw time if unset. -| `font` | [`Font`](../options.md#font) | `{ weight: 'bold' }` | Label font. +| `drawTime` | `string` | `options.drawTime` | See [drawTime](../options#draw-time). Defaults to the line annotation draw time if unset. +| [`font`](#fonts-and-colors) | [`Font`\|`Font[]`](../options#font) | `{ weight: 'bold' }` | Label font. | `height` | `number`\|`string` | `undefined` | Overrides the height of the image or canvas element. Could be set in pixel by a number, or in percentage of current height of image or canvas element by a string. If undefined, uses the height of the image or canvas element. It is used only when the content is an image or canvas element. | `opacity` | `number` | `undefined` | Overrides the opacity of the image or canvas element. Could be set a number in the range 0.0 to 1.0, inclusive. If undefined, uses the opacity of the image or canvas element. It is used only when the content is an image or canvas element. | `padding` | [`Padding`](../options.md#padding) | `6` | The padding to add around the text label. @@ -156,6 +156,10 @@ All of these options can be [Scriptable](../options.md#scriptable-options) If this value is a number, it is applied to all corners of the rectangle (topLeft, topRight, bottomLeft, bottomRight). If this value is an object, the `topLeft` property defines the top-left corners border radius. Similarly, the `topRight`, `bottomLeft`, and `bottomRight` properties can also be specified. Omitted corners have radius of 0. +### Fonts and colors + +When the label to draw has multiple lines, you can use different font and color for each line of the label. This is enabled configuring an array of fonts or colors for those options. When the lines are more than the configured fonts of colors, the last configuration of those options is used for all remaining lines. + ### Callout A callout can connect the label to the line when the label is arbitrarily (by `xAdjust` and `yAdjust` options) moved from its original position. diff --git a/docs/samples/label/fontsColors.md b/docs/samples/label/fontsColors.md new file mode 100644 index 000000000..f52120e76 --- /dev/null +++ b/docs/samples/label/fontsColors.md @@ -0,0 +1,152 @@ +# Fonts and colors + +```js chart-editor +// +const DATA_COUNT = 12; +const MIN = 0; +const MAX = 100; + +const numberCfg = {count: DATA_COUNT, min: MIN, max: MAX}; + +const data = { + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + datasets: [{ + data: Utils.numbers(numberCfg) + }] +}; +// + +// +const annotation1 = { + type: 'label', + backgroundColor: 'rgba(0,0,0,0.2)', + borderRadius: 6, + borderWidth: 0, + callout: { + display: true + }, + color: ['black,', 'black', 'green'], + content: ['March', 'is', 'annotated'], + font: [{size: 16, weight: 'bold'}, {family: 'courier'}], + position: { + x: 'center', + y: 'end' + }, + xValue: 'March', + yAdjust: (ctx) => yOffset(ctx, 'March'), + yValue: (ctx) => yValue(ctx, 'March') +}; +// + +// +const annotation2 = { + type: 'label', + backgroundColor: 'rgba(0,0,0,0.2)', + borderRadius: 6, + borderWidth: 0, + callout: { + display: true + }, + color: ['black,', 'black', 'green'], + content: ['June', 'is', 'annotated'], + font: [{size: 16, weight: 'bold'}, {family: 'courier'}], + position: { + x: 'center', + y: 'end' + }, + xValue: 'June', + yAdjust: (ctx) => yOffset(ctx, 'June'), + yValue: (ctx) => yValue(ctx, 'June') +}; +// + +// +const annotation3 = { + type: 'label', + backgroundColor: 'rgba(0,0,0,0.2)', + borderRadius: 6, + borderWidth: 0, + callout: { + display: true + }, + color: ['black,', 'black', 'green'], + content: ['October', 'is', 'annotated'], + font: [{size: 16, weight: 'bold'}, {family: 'courier'}], + position: { + x: 'center', + y: 'end' + }, + xValue: 'October', + yAdjust: (ctx) => yOffset(ctx, 'October'), + yValue: (ctx) => yValue(ctx, 'October') +}; +// + +// +function yValue(ctx, label) { + const chart = ctx.chart; + const dataset = chart.data.datasets[0]; + return dataset.data[chart.data.labels.indexOf(label)]; +} + +function yOffset(ctx, label) { + const value = yValue(ctx, label); + const chart = ctx.chart; + const scale = chart.scales.y; + const y = scale.getPixelForValue(value); + const lblPos = scale.getPixelForValue(100); + return lblPos - y - 5; +} + +// + +/* */ +const config = { + type: 'bar', + data, + options: { + scales: { + y: { + beginAtZero: true, + max: 130, + min: 0, + grid: { + color: (ctx)=> ctx.tick.value <= 100 ? + ctx.chart.scales.x.options.grid.color : + undefined + }, + ticks: { + callback: (value) => value > 100 ? '' : value + } + } + }, + plugins: { + annotation: { + annotations: { + annotation1, + annotation2, + annotation3 + } + } + } + } +}; +/* */ + +const actions = [ + { + name: 'Randomize', + handler: function(chart) { + chart.data.datasets.forEach(function(dataset, i) { + dataset.data = dataset.data.map(() => Utils.rand(MIN, MAX)); + }); + chart.update(); + } + } +]; + +module.exports = { + actions: actions, + config: config, +}; +``` diff --git a/src/annotation.js b/src/annotation.js index 9d8fec04d..5f26c7942 100644 --- a/src/annotation.js +++ b/src/annotation.js @@ -3,7 +3,7 @@ import {clipArea, unclipArea, isObject, isArray} from 'chart.js/helpers'; import {handleEvent, eventHooks, updateListeners} from './events'; import {invokeHook, elementHooks, updateHooks} from './hooks'; import {adjustScaleRange, verifyScaleOptions} from './scale'; -import {updateElements, resolveType} from './elements'; +import {updateElements, resolveType, isIndexable} from './elements'; import {annotationTypes} from './types'; import {requireVersion} from './helpers'; import {version} from '../package.json'; @@ -137,8 +137,10 @@ export default { }, common: { label: { + _indexable: isIndexable, _fallback: true - } + }, + _indexable: isIndexable } }, diff --git a/src/elements.js b/src/elements.js index ba996cc53..d4df1b3db 100644 --- a/src/elements.js +++ b/src/elements.js @@ -16,6 +16,12 @@ const hooks = eventHooks.concat(elementHooks); * @typedef { import('../../types/options').AnnotationPluginOptions } AnnotationPluginOptions */ +/** + * @param {string} prop + * @returns {boolean} + */ +export const isIndexable = (prop) => prop === 'color' || prop === 'font'; + /** * Resolve the annotation type, checking if is supported. * @param {string} [type=line] - annotation type @@ -126,7 +132,7 @@ function resolveObj(resolver, defs) { for (const prop of Object.keys(defs)) { const optDefs = defs[prop]; const value = resolver[prop]; - result[prop] = isObject(optDefs) ? resolveObj(value, optDefs) : value; + result[prop] = isObject(optDefs) && !isIndexable(prop) ? resolveObj(value, optDefs) : value; } return result; } diff --git a/src/helpers/helpers.canvas.js b/src/helpers/helpers.canvas.js index 703231eae..a8c7525ac 100644 --- a/src/helpers/helpers.canvas.js +++ b/src/helpers/helpers.canvas.js @@ -3,6 +3,10 @@ import {clampAll, clamp} from './helpers.core'; import {calculateTextAlignment, getSize} from './helpers.options'; const widthCache = new Map(); +const fontsKey = (fonts) => fonts.reduce(function(prev, item) { + prev += item.string; + return prev; +}, ''); /** * @typedef { import('chart.js').Point } Point @@ -77,22 +81,13 @@ export function measureLabelSize(ctx, options) { height: getSize(content.height, options.height) }; } - const font = toFont(options.font); + const optFont = options.font; + const fonts = isArray(optFont) ? optFont.map(f => toFont(f)) : [toFont(optFont)]; const strokeWidth = options.textStrokeWidth; const lines = isArray(content) ? content : [content]; - const mapKey = lines.join() + font.string + strokeWidth + (ctx._measureText ? '-spriting' : ''); + const mapKey = lines.join() + fontsKey(fonts) + strokeWidth + (ctx._measureText ? '-spriting' : ''); if (!widthCache.has(mapKey)) { - ctx.save(); - ctx.font = font.string; - const count = lines.length; - let width = 0; - for (let i = 0; i < count; i++) { - const text = lines[i]; - width = Math.max(width, ctx.measureText(text).width + strokeWidth); - } - ctx.restore(); - const height = count * font.lineHeight + strokeWidth; - widthCache.set(mapKey, {width, height}); + widthCache.set(mapKey, calculateLabelSize(ctx, lines, fonts, strokeWidth)); } return widthCache.get(mapKey); } @@ -137,19 +132,19 @@ export function drawLabel(ctx, rect, options) { return; } const labels = isArray(content) ? content : [content]; - const font = toFont(options.font); - const lh = font.lineHeight; + const optFont = options.font; + const fonts = isArray(optFont) ? optFont.map(f => toFont(f)) : [toFont(optFont)]; + const optColor = options.color; + const colors = isArray(optColor) ? optColor : [optColor]; const x = calculateTextAlignment(rect, options); - const y = rect.y + (lh / 2) + options.textStrokeWidth / 2; + const y = rect.y + options.textStrokeWidth / 2; ctx.save(); - ctx.font = font.string; ctx.textBaseline = 'middle'; ctx.textAlign = options.textAlign; if (setTextStrokeStyle(ctx, options)) { - labels.forEach((l, i) => ctx.strokeText(l, x, y + (i * lh))); + applyLabelDecoration(ctx, {x, y}, labels, fonts); } - ctx.fillStyle = options.color; - labels.forEach((l, i) => ctx.fillText(l, x, y + (i * lh))); + applyLabelContent(ctx, {x, y}, labels, {fonts, colors}); ctx.restore(); } @@ -164,6 +159,50 @@ function setTextStrokeStyle(ctx, options) { } } +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 = isNumber(value) ? value : elementValue; return isNumber(opacity) ? clamp(opacity, 0, 1) : 1; diff --git a/test/fixtures/label/contentMultilineColors.js b/test/fixtures/label/contentMultilineColors.js new file mode 100644 index 000000000..06fe09730 --- /dev/null +++ b/test/fixtures/label/contentMultilineColors.js @@ -0,0 +1,56 @@ +module.exports = { + tolerance: 0.0150, + config: { + type: 'bar', + options: { + scales: { + x: { + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + annotations: { + text1: { + type: 'label', + xValue: 'January', + yValue: 20, + color: ['red', 'green'], + font: [{size: 24}, {size: 12}], + content: ['font: [{size: 24}, {size: 12}]', 'This is my text, row 2, longer than other', 'This is my text, row 3'], + position: { + x: 'start', + y: 'center' + }, + textAlign: 'start' + }, + text2: { + type: 'label', + xValue: 'April', + yValue: 10, + color: ['red', 'green', 'blue'], + font: [{size: 16, weight: 'bold'}, {size: 12}, {size: 8}], + content: ['font: [{size: 24, weight: bold}, {size: 12}, {size: 8}]', 'This is my text, row 2, longer than other', 'This is my text, row 3'], + }, + text3: { + type: 'label', + xValue: 'May', + yValue: 15, + color: ['blue', 'green', 'red'], + font: [{size: 12}, {size: 20, family: 'courier'}], + content: ['font: [{size: 12}, {size: 16, style: courier}],', 'This is my text, row 2'], + position: { + x: 'end' + } + }, + } + } + } + } + } +}; diff --git a/test/fixtures/label/contentMultilineColors.png b/test/fixtures/label/contentMultilineColors.png new file mode 100644 index 000000000..65c195998 Binary files /dev/null and b/test/fixtures/label/contentMultilineColors.png differ diff --git a/test/fixtures/label/contentMultilineFonts.js b/test/fixtures/label/contentMultilineFonts.js new file mode 100644 index 000000000..38ec3f11c --- /dev/null +++ b/test/fixtures/label/contentMultilineFonts.js @@ -0,0 +1,53 @@ +module.exports = { + tolerance: 0.0165, + config: { + type: 'bar', + options: { + scales: { + x: { + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], + }, + y: { + display: false, + min: 0, + max: 25 + } + }, + plugins: { + annotation: { + annotations: { + text1: { + type: 'label', + xValue: 'January', + yValue: 20, + font: [{size: 24}, {size: 12}], + content: ['font: [{size: 24}, {size: 12}]', 'This is my text, row 2, longer than other', 'This is my text, row 3'], + position: { + x: 'start', + y: 'center' + }, + textAlign: 'start' + }, + text2: { + type: 'label', + xValue: 'April', + yValue: 10, + font: [{size: 16, weight: 'bold'}, {size: 12}, {size: 8}], + content: ['font: [{size: 24, weight: bold}, {size: 12}, {size: 8}]', 'This is my text, row 2, longer than other', 'This is my text, row 3'], + }, + text3: { + type: 'label', + xValue: 'May', + yValue: 15, + font: [{size: 12}, {size: 20, family: 'courier'}], + content: ['font: [{size: 12}, {size: 16, style: courier}],', 'This is my text, row 2'], + position: { + x: 'end' + } + }, + } + } + } + } + } +}; diff --git a/test/fixtures/label/contentMultilineFonts.png b/test/fixtures/label/contentMultilineFonts.png new file mode 100644 index 000000000..283eaf9f4 Binary files /dev/null and b/test/fixtures/label/contentMultilineFonts.png differ diff --git a/types/label.d.ts b/types/label.d.ts index c5cc688e3..4c66511f2 100644 --- a/types/label.d.ts +++ b/types/label.d.ts @@ -39,8 +39,8 @@ export interface CalloutOptions { export interface CoreLabelOptions { drawTime?: Scriptable, - font?: Scriptable, PartialEventContext>, - color?: Scriptable, + font?: Scriptable | Partial[], PartialEventContext>, + color?: Scriptable, /** * Padding of label * @default 6