Skip to content

Commit

Permalink
Doughnut/Pie chart border radius (#8682)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
etimberg committed Apr 3, 2021
1 parent 96dd201 commit 6f6b1b2
Show file tree
Hide file tree
Showing 12 changed files with 273 additions and 13 deletions.
5 changes: 5 additions & 0 deletions docs/charts/doughnut.md
Expand Up @@ -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`
Expand Down Expand Up @@ -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:
Expand Down
137 changes: 127 additions & 10 deletions 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;
Expand All @@ -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();
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}

Expand Down Expand Up @@ -215,9 +331,10 @@ ArcElement.id = 'arc';
ArcElement.defaults = {
borderAlign: 'center',
borderColor: '#fff',
borderRadius: 0,
borderWidth: 2,
offset: 0,
angle: undefined
angle: undefined,
};

/**
Expand Down
6 changes: 3 additions & 3 deletions src/helpers/helpers.options.js
Expand Up @@ -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;
Expand All @@ -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'});
}

/**
Expand All @@ -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']);
}

/**
Expand Down
29 changes: 29 additions & 0 deletions 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
}
}
};
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions 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
}
}
};
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
@@ -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
}
}
};
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
@@ -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
}
}
};
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions types/index.esm.d.ts
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down

0 comments on commit 6f6b1b2

Please sign in to comment.