From: Evert Timberg Date: Sat, 3 Apr 2021 11:58:51 +0000 (-0400) Subject: Doughnut/Pie chart border radius (#8682) X-Git-Tag: v3.1.0~46 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=6f6b1b2d1774e6050c169421de72e320b68978f1;p=thirdparty%2FChart.js.git Doughnut/Pie chart border radius (#8682) * Arc with rounded ends implementation * End style option * Working border radius implementation for arcs * Linting * Fix bug introduced when converting to new border object * Fix bugs identified by tests * Arc border radius tests * Add test to cover small borderRadii * Reduce the weight of the arc border implementation * lint fix --- diff --git a/docs/charts/doughnut.md b/docs/charts/doughnut.md index f3d01d13a..66c31125c 100644 --- a/docs/charts/doughnut.md +++ b/docs/charts/doughnut.md @@ -97,6 +97,7 @@ The doughnut/pie chart allows a number of properties to be specified for each da | [`backgroundColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'rgba(0, 0, 0, 0.1)'` | [`borderAlign`](#border-alignment) | `string` | Yes | Yes | `'center'` | [`borderColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'#fff'` +| [`borderRadius`](#border-radius) | `number`\|`object` | Yes | Yes | `0` | [`borderWidth`](#styling) | `number` | Yes | Yes | `2` | [`circumference`](#general) | `number` | - | - | `undefined` | [`clip`](#general) | `number`\|`object` | - | - | `undefined` @@ -140,6 +141,10 @@ The following values are supported for `borderAlign`. When `'center'` is set, the borders of arcs next to each other will overlap. When `'inner'` is set, it is guaranteed that all borders will not overlap. +### Border Radius + +If this value is a number, it is applied to all corners of the arc (outerStart, outerEnd, innerStart, innerRight). If this value is an object, the `outerStart` property defines the outer-start corner's border radius. Similarly, the `outerEnd`, `innerStart`, and `innerEnd` properties can also be specified. + ### Interactions The interaction with each arc can be controlled with the following properties: diff --git a/src/elements/element.arc.js b/src/elements/element.arc.js index 376b8ebe9..2bdfd387d 100644 --- a/src/elements/element.arc.js +++ b/src/elements/element.arc.js @@ -1,5 +1,7 @@ import Element from '../core/core.element'; import {_angleBetween, getAngleFromPoint, TAU, HALF_PI} from '../helpers/index'; +import {_limitValue} from '../helpers/helpers.math'; +import {_readValueToProps} from '../helpers/helpers.options'; function clipArc(ctx, element) { const {startAngle, endAngle, pixelMargin, x, y, outerRadius, innerRadius} = element; @@ -19,15 +21,134 @@ function clipArc(ctx, element) { ctx.clip(); } +function toRadiusCorners(value) { + return _readValueToProps(value, ['outerStart', 'outerEnd', 'innerStart', 'innerEnd']); +} + +/** + * Parse border radius from the provided options + * @param {ArcElement} arc + * @param {number} innerRadius + * @param {number} outerRadius + * @param {number} angleDelta Arc circumference in radians + * @returns + */ +function parseBorderRadius(arc, innerRadius, outerRadius, angleDelta) { + const o = toRadiusCorners(arc.options.borderRadius); + const halfThickness = (outerRadius - innerRadius) / 2; + const innerLimit = Math.min(halfThickness, angleDelta * innerRadius / 2); + + // Outer limits are complicated. We want to compute the available angular distance at + // a radius of outerRadius - borderRadius because for small angular distances, this term limits. + // We compute at r = outerRadius - borderRadius because this circle defines the center of the border corners. + // + // If the borderRadius is large, that value can become negative. + // This causes the outer borders to lose their radius entirely, which is rather unexpected. To solve that, if borderRadius > outerRadius + // we know that the thickness term will dominate and compute the limits at that point + const computeOuterLimit = (val) => { + const outerArcLimit = (outerRadius - Math.min(halfThickness, val)) * angleDelta / 2; + return _limitValue(val, 0, Math.min(halfThickness, outerArcLimit)); + }; + + return { + outerStart: computeOuterLimit(o.outerStart), + outerEnd: computeOuterLimit(o.outerEnd), + innerStart: _limitValue(o.innerStart, 0, innerLimit), + innerEnd: _limitValue(o.innerEnd, 0, innerLimit), + }; +} + +/** + * Convert (r, 𝜃) to (x, y) + * @param {number} r Radius from center point + * @param {number} theta Angle in radians + * @param {number} x Center X coordinate + * @param {number} y Center Y coordinate + * @returns {{ x: number; y: number }} Rectangular coordinate point + */ +function rThetaToXY(r, theta, x, y) { + return { + x: x + r * Math.cos(theta), + y: y + r * Math.sin(theta), + }; +} + +/** + * Path the arc, respecting the border radius + * + * 8 points of interest exist around the arc segment. + * These points define the intersection of the arc edges and the corners. + * + * Start End + * + * 1---------2 Outer + * / \ + * 8 3 + * | | + * | | + * 7 4 + * \ / + * 6---------5 Inner + * @param {CanvasRenderingContext2D} ctx + * @param {ArcElement} element + */ function pathArc(ctx, element) { const {x, y, startAngle, endAngle, pixelMargin} = element; const outerRadius = Math.max(element.outerRadius - pixelMargin, 0); const innerRadius = element.innerRadius + pixelMargin; + const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius(element, innerRadius, outerRadius, endAngle - startAngle); + + const outerStartAdjustedRadius = outerRadius - outerStart; + const outerEndAdjustedRadius = outerRadius - outerEnd; + const outerStartAdjustedAngle = startAngle + outerStart / outerStartAdjustedRadius; + const outerEndAdjustedAngle = endAngle - outerEnd / outerEndAdjustedRadius; + + const innerStartAdjustedRadius = innerRadius + innerStart; + const innerEndAdjustedRadius = innerRadius + innerEnd; + const innerStartAdjustedAngle = startAngle + innerStart / innerStartAdjustedRadius; + const innerEndAdjustedAngle = endAngle - innerEnd / innerEndAdjustedRadius; ctx.beginPath(); - ctx.arc(x, y, outerRadius, startAngle, endAngle); - ctx.arc(x, y, innerRadius, endAngle, startAngle, true); + + // The first arc segment from point 1 to point 2 + ctx.arc(x, y, outerRadius, outerStartAdjustedAngle, outerEndAdjustedAngle); + + // The corner segment from point 2 to point 3 + if (outerEnd > 0) { + const pCenter = rThetaToXY(outerEndAdjustedRadius, outerEndAdjustedAngle, x, y); + ctx.arc(pCenter.x, pCenter.y, outerEnd, outerEndAdjustedAngle, endAngle + HALF_PI); + } + + // The line from point 3 to point 4 + const p4 = rThetaToXY(innerEndAdjustedRadius, endAngle, x, y); + ctx.lineTo(p4.x, p4.y); + + // The corner segment from point 4 to point 5 + if (innerEnd > 0) { + const pCenter = rThetaToXY(innerEndAdjustedRadius, innerEndAdjustedAngle, x, y); + ctx.arc(pCenter.x, pCenter.y, innerEnd, endAngle + HALF_PI, innerEndAdjustedAngle + Math.PI); + } + + // The inner arc from point 5 to point 6 + ctx.arc(x, y, innerRadius, endAngle - (innerEnd / innerRadius), startAngle + (innerStart / innerRadius), true); + + // The corner segment from point 6 to point 7 + if (innerStart > 0) { + const pCenter = rThetaToXY(innerStartAdjustedRadius, innerStartAdjustedAngle, x, y); + ctx.arc(pCenter.x, pCenter.y, innerStart, innerStartAdjustedAngle + Math.PI, startAngle - HALF_PI); + } + + // The line from point 7 to point 8 + const p8 = rThetaToXY(outerStartAdjustedRadius, startAngle, x, y); + ctx.lineTo(p8.x, p8.y); + + // The corner segment from point 8 to point 1 + if (outerStart > 0) { + const pCenter = rThetaToXY(outerStartAdjustedRadius, outerStartAdjustedAngle, x, y); + ctx.arc(pCenter.x, pCenter.y, outerStart, startAngle - HALF_PI, outerStartAdjustedAngle); + } + ctx.closePath(); } @@ -80,9 +201,7 @@ function drawFullCircleBorders(ctx, element, inner) { } function drawBorder(ctx, element) { - const {x, y, startAngle, endAngle, pixelMargin, options} = element; - const outerRadius = element.outerRadius; - const innerRadius = element.innerRadius + pixelMargin; + const {options} = element; const inner = options.borderAlign === 'inner'; if (!options.borderWidth) { @@ -105,10 +224,7 @@ function drawBorder(ctx, element) { clipArc(ctx, element); } - ctx.beginPath(); - ctx.arc(x, y, outerRadius, startAngle, endAngle); - ctx.arc(x, y, innerRadius, endAngle, startAngle, true); - ctx.closePath(); + pathArc(ctx, element); ctx.stroke(); } @@ -215,9 +331,10 @@ ArcElement.id = 'arc'; ArcElement.defaults = { borderAlign: 'center', borderColor: '#fff', + borderRadius: 0, borderWidth: 2, offset: 0, - angle: undefined + angle: undefined, }; /** diff --git a/src/helpers/helpers.options.js b/src/helpers/helpers.options.js index 6286b3e32..70846e725 100644 --- a/src/helpers/helpers.options.js +++ b/src/helpers/helpers.options.js @@ -39,7 +39,7 @@ export function toLineHeight(value, size) { const numberOrZero = v => +v || 0; -function readValueToProps(value, props) { +export function _readValueToProps(value, props) { const ret = {}; const objProps = isObject(props); const keys = objProps ? Object.keys(props) : props; @@ -64,7 +64,7 @@ function readValueToProps(value, props) { * @since 3.0.0 */ export function toTRBL(value) { - return readValueToProps(value, {top: 'y', right: 'x', bottom: 'y', left: 'x'}); + return _readValueToProps(value, {top: 'y', right: 'x', bottom: 'y', left: 'x'}); } /** @@ -75,7 +75,7 @@ export function toTRBL(value) { * @since 3.0.0 */ export function toTRBLCorners(value) { - return readValueToProps(value, ['topLeft', 'topRight', 'bottomLeft', 'bottomRight']); + return _readValueToProps(value, ['topLeft', 'topRight', 'bottomLeft', 'bottomRight']); } /** diff --git a/test/fixtures/controller.doughnut/borderRadius/scriptable.js b/test/fixtures/controller.doughnut/borderRadius/scriptable.js new file mode 100644 index 000000000..9e8581054 --- /dev/null +++ b/test/fixtures/controller.doughnut/borderRadius/scriptable.js @@ -0,0 +1,29 @@ +module.exports = { + config: { + type: 'doughnut', + data: { + labels: [0, 1, 2, 3, 4, 5], + datasets: [ + { + // option in dataset + data: [0, 2, 4, null, 6, 8], + borderRadius: () => 4, + }, + ] + }, + options: { + elements: { + arc: { + backgroundColor: 'transparent', + borderColor: '#888', + } + }, + } + }, + options: { + canvas: { + height: 256, + width: 512 + } + } +}; diff --git a/test/fixtures/controller.doughnut/borderRadius/scriptable.png b/test/fixtures/controller.doughnut/borderRadius/scriptable.png new file mode 100644 index 000000000..15010e3af Binary files /dev/null and b/test/fixtures/controller.doughnut/borderRadius/scriptable.png differ diff --git a/test/fixtures/controller.doughnut/borderRadius/value-corners.js b/test/fixtures/controller.doughnut/borderRadius/value-corners.js new file mode 100644 index 000000000..d6f473115 --- /dev/null +++ b/test/fixtures/controller.doughnut/borderRadius/value-corners.js @@ -0,0 +1,32 @@ +module.exports = { + config: { + type: 'doughnut', + data: { + labels: [0, 1, 2, 3, 4, 5], + datasets: [ + { + // option in dataset + data: [0, 2, 4, null, 6, 8], + borderRadius: { + outerStart: 20, + outerEnd: 40, + } + }, + ] + }, + options: { + elements: { + arc: { + backgroundColor: 'transparent', + borderColor: '#888', + } + }, + } + }, + options: { + canvas: { + height: 256, + width: 512 + } + } +}; diff --git a/test/fixtures/controller.doughnut/borderRadius/value-corners.png b/test/fixtures/controller.doughnut/borderRadius/value-corners.png new file mode 100644 index 000000000..ec74b2901 Binary files /dev/null and b/test/fixtures/controller.doughnut/borderRadius/value-corners.png differ diff --git a/test/fixtures/controller.doughnut/borderRadius/value-large-radius.js b/test/fixtures/controller.doughnut/borderRadius/value-large-radius.js new file mode 100644 index 000000000..141c265d0 --- /dev/null +++ b/test/fixtures/controller.doughnut/borderRadius/value-large-radius.js @@ -0,0 +1,36 @@ +module.exports = { + config: { + type: 'doughnut', + data: { + labels: [0, 1, 2, 3, 4, 5], + datasets: [ + { + data: [60, 15, 33, 44, 12], + // Radius is large enough to clip + borderRadius: 200, + backgroundColor: [ + 'rgb(255, 99, 132)', + 'rgb(255, 159, 64)', + 'rgb(255, 205, 86)', + 'rgb(75, 192, 192)', + 'rgb(54, 162, 235)' + ] + }, + ] + }, + // options: { + // elements: { + // arc: { + // backgroundColor: 'transparent', + // borderColor: '#888', + // } + // }, + // } + }, + options: { + canvas: { + height: 256, + width: 512 + } + } +}; diff --git a/test/fixtures/controller.doughnut/borderRadius/value-large-radius.png b/test/fixtures/controller.doughnut/borderRadius/value-large-radius.png new file mode 100644 index 000000000..583e7d202 Binary files /dev/null and b/test/fixtures/controller.doughnut/borderRadius/value-large-radius.png differ diff --git a/test/fixtures/controller.doughnut/borderRadius/value-small-number.js b/test/fixtures/controller.doughnut/borderRadius/value-small-number.js new file mode 100644 index 000000000..31db84346 --- /dev/null +++ b/test/fixtures/controller.doughnut/borderRadius/value-small-number.js @@ -0,0 +1,29 @@ +module.exports = { + config: { + type: 'doughnut', + data: { + labels: [0, 1, 2, 3, 4, 5], + datasets: [ + { + // option in dataset + data: [0, 2, 4, null, 6, 8], + borderRadius: 20 + }, + ] + }, + options: { + elements: { + arc: { + backgroundColor: 'transparent', + borderColor: '#888', + } + }, + } + }, + options: { + canvas: { + height: 256, + width: 512 + } + } +}; diff --git a/test/fixtures/controller.doughnut/borderRadius/value-small-number.png b/test/fixtures/controller.doughnut/borderRadius/value-small-number.png new file mode 100644 index 000000000..375c053f9 Binary files /dev/null and b/test/fixtures/controller.doughnut/borderRadius/value-small-number.png differ diff --git a/types/index.esm.d.ts b/types/index.esm.d.ts index cf2a4961c..f2a911829 100644 --- a/types/index.esm.d.ts +++ b/types/index.esm.d.ts @@ -1604,6 +1604,13 @@ export interface ArcProps { circumference: number; } +export interface ArcBorderRadius { + outerStart: number; + outerEnd: number; + innerStart: number; + innerEnd: number; +} + export interface ArcOptions extends CommonElementOptions { /** * Arc stroke alignment. @@ -1613,6 +1620,11 @@ export interface ArcOptions extends CommonElementOptions { * Arc offset (in pixels). */ offset: number; + /** + * Sets the border radius for arcs + * @default 0 + */ + borderRadius: number | ArcBorderRadius; } export interface ArcHoverOptions extends CommonHoverOptions {