mirror of
https://github.com/chartjs/Chart.js.git
synced 2025-12-08 20:36:08 +00:00
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
This commit is contained in:
parent
96dd201028
commit
6f6b1b2d17
@ -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:
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
29
test/fixtures/controller.doughnut/borderRadius/scriptable.js
vendored
Normal file
29
test/fixtures/controller.doughnut/borderRadius/scriptable.js
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
BIN
test/fixtures/controller.doughnut/borderRadius/scriptable.png
vendored
Normal file
BIN
test/fixtures/controller.doughnut/borderRadius/scriptable.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
32
test/fixtures/controller.doughnut/borderRadius/value-corners.js
vendored
Normal file
32
test/fixtures/controller.doughnut/borderRadius/value-corners.js
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
BIN
test/fixtures/controller.doughnut/borderRadius/value-corners.png
vendored
Normal file
BIN
test/fixtures/controller.doughnut/borderRadius/value-corners.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
36
test/fixtures/controller.doughnut/borderRadius/value-large-radius.js
vendored
Normal file
36
test/fixtures/controller.doughnut/borderRadius/value-large-radius.js
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
BIN
test/fixtures/controller.doughnut/borderRadius/value-large-radius.png
vendored
Normal file
BIN
test/fixtures/controller.doughnut/borderRadius/value-large-radius.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
29
test/fixtures/controller.doughnut/borderRadius/value-small-number.js
vendored
Normal file
29
test/fixtures/controller.doughnut/borderRadius/value-small-number.js
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
BIN
test/fixtures/controller.doughnut/borderRadius/value-small-number.png
vendored
Normal file
BIN
test/fixtures/controller.doughnut/borderRadius/value-small-number.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
12
types/index.esm.d.ts
vendored
12
types/index.esm.d.ts
vendored
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user