]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Doughnut/Pie chart border radius (#8682)
authorEvert Timberg <evert.timberg+github@gmail.com>
Sat, 3 Apr 2021 11:58:51 +0000 (07:58 -0400)
committerGitHub <noreply@github.com>
Sat, 3 Apr 2021 11:58:51 +0000 (07:58 -0400)
* 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

12 files changed:
docs/charts/doughnut.md
src/elements/element.arc.js
src/helpers/helpers.options.js
test/fixtures/controller.doughnut/borderRadius/scriptable.js [new file with mode: 0644]
test/fixtures/controller.doughnut/borderRadius/scriptable.png [new file with mode: 0644]
test/fixtures/controller.doughnut/borderRadius/value-corners.js [new file with mode: 0644]
test/fixtures/controller.doughnut/borderRadius/value-corners.png [new file with mode: 0644]
test/fixtures/controller.doughnut/borderRadius/value-large-radius.js [new file with mode: 0644]
test/fixtures/controller.doughnut/borderRadius/value-large-radius.png [new file with mode: 0644]
test/fixtures/controller.doughnut/borderRadius/value-small-number.js [new file with mode: 0644]
test/fixtures/controller.doughnut/borderRadius/value-small-number.png [new file with mode: 0644]
types/index.esm.d.ts

index f3d01d13a478abc0e14b125295cbccdf143cc7b3..66c31125c1573eeec14c3aeedf2332cb8541d6fa 100644 (file)
@@ -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:
index 376b8ebe9c188d7ceedd1c41ce4b8424c993e1db..2bdfd387dba417e87f8d3b522c757e9fc8c1ade6 100644 (file)
@@ -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,
 };
 
 /**
index 6286b3e32c42bcb8081a9d4e27e13d00b9696095..70846e725eaf8a36b60d0e1db0d79f86dc51c257 100644 (file)
@@ -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 (file)
index 0000000..9e85810
--- /dev/null
@@ -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 (file)
index 0000000..15010e3
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 (file)
index 0000000..d6f4731
--- /dev/null
@@ -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 (file)
index 0000000..ec74b29
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 (file)
index 0000000..141c265
--- /dev/null
@@ -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 (file)
index 0000000..583e7d2
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 (file)
index 0000000..31db843
--- /dev/null
@@ -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 (file)
index 0000000..375c053
Binary files /dev/null and b/test/fixtures/controller.doughnut/borderRadius/value-small-number.png differ
index cf2a4961c45d85976050e3fff7ff62f9ef289dd4..f2a911829bcde23329cfa68b88844f57123a5069 100644 (file)
@@ -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 {