]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Add borderRadius to bar charts. Closes #7701 (#7951)
authorDan Manastireanu <498419+danmana@users.noreply.github.com>
Mon, 26 Oct 2020 14:05:24 +0000 (16:05 +0200)
committerGitHub <noreply@github.com>
Mon, 26 Oct 2020 14:05:24 +0000 (10:05 -0400)
* Add helper to parse border radius options
* feat: Implement borderRadius for bar charts
* chore: add demo of bar charts with border radius
* chore: document bar borderRadius
* chore: update typescript with bar borderRadius property
* fix horizontal borders test failing due to antialiasing
* chore: Add border-radius visual test

docs/docs/charts/bar.mdx
samples/charts/bar/border-radius.html [new file with mode: 0644]
samples/samples.js
src/controllers/controller.bar.js
src/elements/element.bar.js
src/helpers/helpers.options.js
test/fixtures/controller.bar/border-radius.js [new file with mode: 0644]
test/fixtures/controller.bar/border-radius.png [new file with mode: 0644]
test/fixtures/controller.bar/horizontal-borders.png
test/specs/helpers.options.tests.js
types/elements/index.d.ts

index eeb39d9284fde201c6671ecf97d64cdd7f3a96a2..2a96460a1feb0589884c74b33d474ffb139258d3 100644 (file)
@@ -83,11 +83,13 @@ the color of the bars is generally set this way.
 | [`borderColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'rgba(0, 0, 0, 0.1)'`
 | [`borderSkipped`](#borderskipped) | `string` | Yes | Yes | `'start'`
 | [`borderWidth`](#borderwidth) | <code>number&#124;object</code> | Yes | Yes | `0`
+| [`borderRadius`](#borderradius) | <code>number&#124;object</code> | Yes | Yes | `0`
 | [`clip`](#general) | <code>number&#124;object</code> | - | - | `undefined`
 | [`data`](#data-structure) | `object[]` | - | - | **required**
 | [`hoverBackgroundColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
 | [`hoverBorderColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
 | [`hoverBorderWidth`](#interactions) | `number` | Yes | Yes | `1`
+| [`hoverBorderRadius`](#interactions) | `number` | Yes | Yes | `0`
 | [`indexAxis`](#general) | `string` | `'x'` | The base axis for the dataset. Use `'y'` for horizontal bar.
 | [`label`](#general) | `string` | - | - | `''`
 | [`order`](#general) | `number` | - | - | `0`
@@ -116,13 +118,14 @@ The style of each bar can be controlled with the following properties:
 | `borderColor` | The bar border color.
 | [`borderSkipped`](#borderskipped) | The edge to skip when drawing bar.
 | [`borderWidth`](#borderwidth) | The bar border width (in pixels).
+| [`borderRadius`](#borderradius) | The bar border radius (in pixels).
 | `clip` | How to clip relative to chartArea. Positive value allows overflow, negative value clips that many pixels inside chartArea. `0` = clip at chartArea. Clipping can also be configured per side: `clip: {left: 5, top: false, right: -2, bottom: 0}`
 
 All these values, if `undefined`, fallback to the associated [`elements.bar.*`](../configuration/elements.md#bar-configuration) options.
 
 #### borderSkipped
 
-This setting is used to avoid drawing the bar stroke at the base of the fill.
+This setting is used to avoid drawing the bar stroke at the base of the fill, or disable the border radius.
 In general, this does not need to be changed except when creating chart types
 that derive from a bar chart.
 
@@ -142,6 +145,10 @@ Options are:
 
 If this value is a number, it is applied to all sides of the rectangle (left, top, right, bottom), except [`borderSkipped`](#borderskipped). If this value is an object, the `left` property defines the left border width. Similarly, the `right`, `top`, and `bottom` properties can also be specified. Omitted borders and [`borderSkipped`](#borderskipped) are skipped.
 
+#### borderRadius
+
+If this value is a number, it is applied to all corners of the rectangle (topLeft, topRight, bottomLeft, bottomRight), except corners touching the [`borderSkipped`](#borderskipped). 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 and those touching the [`borderSkipped`](#borderskipped) are skipped. For example if the `top` border is skipped, the border radius for the corners `topLeft` and `topRight` will be skipped as well.
+
 ### Interactions
 
 The interaction with each bar can be controlled with the following properties:
@@ -151,6 +158,7 @@ The interaction with each bar can be controlled with the following properties:
 | `hoverBackgroundColor` | The bar background color when hovered.
 | `hoverBorderColor` | The bar border color when hovered.
 | `hoverBorderWidth` | The bar border width when hovered (in pixels).
+| `hoverBorderRadius` | The bar border radius when hovered (in pixels).
 
 All these values, if `undefined`, fallback to the associated [`elements.bar.*`](../configuration/elements.md#bar-configuration) options.
 
diff --git a/samples/charts/bar/border-radius.html b/samples/charts/bar/border-radius.html
new file mode 100644 (file)
index 0000000..b2abcfa
--- /dev/null
@@ -0,0 +1,147 @@
+<!doctype html>
+<html>
+
+<head>
+       <title>Bar Chart</title>
+       <script src="../../../dist/chart.min.js"></script>
+       <script src="../../utils.js"></script>
+       <style>
+       canvas {
+               -moz-user-select: none;
+               -webkit-user-select: none;
+               -ms-user-select: none;
+       }
+       </style>
+</head>
+
+<body>
+       <div id="container" style="width: 75%;">
+               <canvas id="canvas"></canvas>
+       </div>
+       <button id="randomizeData">Randomize Data</button>
+       <button id="addDataset">Add Dataset</button>
+       <button id="removeDataset">Remove Dataset</button>
+       <button id="addData">Add Data</button>
+       <button id="removeData">Remove Data</button>
+       <script>
+               var MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+               var color = Chart.helpers.color;
+               var barChartData = {
+                       labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
+                       datasets: [{
+                               label: 'Fully Rounded',
+                               backgroundColor: color(window.chartColors.red).alpha(0.5).rgbString(),
+                               borderColor: window.chartColors.red,
+                               borderWidth: 2,
+                               borderRadius: Number.MAX_VALUE,
+                               borderSkipped: false,
+                               data: [
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor()
+                               ]
+                       }, {
+                               label: 'Small Radius',
+                               backgroundColor: color(window.chartColors.blue).alpha(0.5).rgbString(),
+                               borderColor: window.chartColors.blue,
+                               borderWidth: 2,
+                               borderRadius: 5,
+                               data: [
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor(),
+                                       randomScalingFactor()
+                               ]
+                       }]
+
+               };
+
+               window.onload = function() {
+                       var ctx = document.getElementById('canvas').getContext('2d');
+                       window.myBar = new Chart(ctx, {
+                               type: 'bar',
+                               data: barChartData,
+                               options: {
+                                       responsive: true,
+                                       legend: {
+                                               position: 'top',
+                                       },
+                                       title: {
+                                               display: true,
+                                               text: 'Chart.js Bar Chart'
+                                       }
+                               }
+                       });
+
+               };
+
+               document.getElementById('randomizeData').addEventListener('click', function() {
+                       var zero = Math.random() < 0.2 ? true : false;
+                       barChartData.datasets.forEach(function(dataset) {
+                               dataset.data = dataset.data.map(function() {
+                                       return zero ? 0.0 : randomScalingFactor();
+                               });
+
+                       });
+                       window.myBar.update();
+               });
+
+               var colorNames = Object.keys(window.chartColors);
+               document.getElementById('addDataset').addEventListener('click', function() {
+                       var colorName = colorNames[barChartData.datasets.length % colorNames.length];
+                       var dsColor = window.chartColors[colorName];
+                       var newDataset = {
+                               label: 'Dataset ' + (barChartData.datasets.length + 1),
+                               backgroundColor: color(dsColor).alpha(0.5).rgbString(),
+                               borderColor: dsColor,
+                               borderWidth: 2,
+                               borderRadius: Math.floor(Math.random() * 20),
+                               data: []
+                       };
+
+                       for (var index = 0; index < barChartData.labels.length; ++index) {
+                               newDataset.data.push(randomScalingFactor());
+                       }
+
+                       barChartData.datasets.push(newDataset);
+                       window.myBar.update();
+               });
+
+               document.getElementById('addData').addEventListener('click', function() {
+                       if (barChartData.datasets.length > 0) {
+                               var month = MONTHS[barChartData.labels.length % MONTHS.length];
+                               barChartData.labels.push(month);
+
+                               for (var index = 0; index < barChartData.datasets.length; ++index) {
+                                       barChartData.datasets[index].data.push(randomScalingFactor());
+                               }
+
+                               window.myBar.update();
+                       }
+               });
+
+               document.getElementById('removeDataset').addEventListener('click', function() {
+                       barChartData.datasets.pop();
+                       window.myBar.update();
+               });
+
+               document.getElementById('removeData').addEventListener('click', function() {
+                       barChartData.labels.splice(-1, 1); // remove the label first
+
+                       barChartData.datasets.forEach(function(dataset) {
+                               dataset.data.pop();
+                       });
+
+                       window.myBar.update();
+               });
+       </script>
+</body>
+
+</html>
index 57cac6b3adf18b3355a6b04fd632542567797e2b..eb569df52e71b62c374c30e7cd2c9f3f1f75b5ab 100644 (file)
@@ -22,6 +22,9 @@
                }, {
                        title: 'Floating',
                        path: 'charts/bar/float.html'
+               }, {
+                       title: 'Border Radius',
+                       path: 'charts/bar/border-radius.html'
                }]
        }, {
                title: 'Line charts',
index 6ca1e4f679524f7a835f7814748cceaa3bde094f..128f0fc033deafb02057eaf7a7f9975b5e3a17e6 100644 (file)
@@ -521,6 +521,7 @@ BarController.defaults = {
                'borderColor',
                'borderSkipped',
                'borderWidth',
+               'borderRadius',
                'barPercentage',
                'barThickness',
                'base',
index 5e4f04ab28482f812da7605627d116a8e35109d9..1375e9276ea4985de0f969cfc310ae1b470276ed 100644 (file)
@@ -1,5 +1,6 @@
 import Element from '../core/core.element';
-import {toTRBL} from '../helpers/helpers.options';
+import {toTRBL, toTRBLCorners} from '../helpers/helpers.options';
+import {PI, HALF_PI} from '../helpers/helpers.math';
 
 /**
  * Helper function to get the bounds of the bar regardless of the orientation
@@ -81,24 +82,46 @@ function parseBorderWidth(bar, maxW, maxH) {
        };
 }
 
+function parseBorderRadius(bar, maxW, maxH) {
+       const value = bar.options.borderRadius;
+       const o = toTRBLCorners(value);
+       const maxR = Math.min(maxW, maxH);
+       const skip = parseBorderSkipped(bar);
+
+       return {
+               topLeft: skipOrLimit(skip.top || skip.left, o.topLeft, 0, maxR),
+               topRight: skipOrLimit(skip.top || skip.right, o.topRight, 0, maxR),
+               bottomLeft: skipOrLimit(skip.bottom || skip.left, o.bottomLeft, 0, maxR),
+               bottomRight: skipOrLimit(skip.bottom || skip.right, o.bottomRight, 0, maxR)
+       };
+}
+
 function boundingRects(bar) {
        const bounds = getBarBounds(bar);
        const width = bounds.right - bounds.left;
        const height = bounds.bottom - bounds.top;
        const border = parseBorderWidth(bar, width / 2, height / 2);
+       const radius = parseBorderRadius(bar, width / 2, height / 2);
 
        return {
                outer: {
                        x: bounds.left,
                        y: bounds.top,
                        w: width,
-                       h: height
+                       h: height,
+                       radius
                },
                inner: {
                        x: bounds.left + border.l,
                        y: bounds.top + border.t,
                        w: width - border.l - border.r,
-                       h: height - border.t - border.b
+                       h: height - border.t - border.b,
+                       radius: {
+                               topLeft: Math.max(0, radius.topLeft - Math.max(border.t, border.l)),
+                               topRight: Math.max(0, radius.topRight - Math.max(border.t, border.r)),
+                               bottomLeft: Math.max(0, radius.bottomLeft - Math.max(border.b, border.l)),
+                               bottomRight: Math.max(0, radius.bottomRight - Math.max(border.b, border.r)),
+                       }
                }
        };
 }
@@ -114,6 +137,52 @@ function inRange(bar, x, y, useFinalPosition) {
                && (skipY || y >= bounds.top && y <= bounds.bottom);
 }
 
+function hasRadius(radius) {
+       return radius.topLeft || radius.topRight || radius.bottomLeft || radius.bottomRight;
+}
+
+/**
+ * Add a path of a rectangle with rounded corners to the current sub-path
+ * @param {CanvasRenderingContext2D} ctx Context
+ * @param {*} rect Bounding rect
+ */
+function addRoundedRectPath(ctx, rect) {
+       const {x, y, w, h, radius} = rect;
+
+       // top left arc
+       ctx.arc(x + radius.topLeft, y + radius.topLeft, radius.topLeft, -HALF_PI, PI, true);
+
+       // line from top left to bottom left
+       ctx.lineTo(x, y + h - radius.bottomLeft);
+
+       // bottom left arc
+       ctx.arc(x + radius.bottomLeft, y + h - radius.bottomLeft, radius.bottomLeft, PI, HALF_PI, true);
+
+       // line from bottom left to bottom right
+       ctx.lineTo(x + w - radius.bottomRight, y + h);
+
+       // bottom right arc
+       ctx.arc(x + w - radius.bottomRight, y + h - radius.bottomRight, radius.bottomRight, HALF_PI, 0, true);
+
+       // line from bottom right to top right
+       ctx.lineTo(x + w, y + radius.topRight);
+
+       // top right arc
+       ctx.arc(x + w - radius.topRight, y + radius.topRight, radius.topRight, 0, -HALF_PI, true);
+
+       // line from top right to top left
+       ctx.lineTo(x + radius.topLeft, y);
+}
+
+/**
+ * Add a path of a rectangle to the current sub-path
+ * @param {CanvasRenderingContext2D} ctx Context
+ * @param {*} rect Bounding rect
+ */
+function addNormalRectPath(ctx, rect) {
+       ctx.rect(rect.x, rect.y, rect.w, rect.h);
+}
+
 export default class BarElement extends Element {
 
        constructor(cfg) {
@@ -133,20 +202,23 @@ export default class BarElement extends Element {
        draw(ctx) {
                const options = this.options;
                const {inner, outer} = boundingRects(this);
+               const addRectPath = hasRadius(outer.radius) ? addRoundedRectPath : addNormalRectPath;
 
                ctx.save();
 
                if (outer.w !== inner.w || outer.h !== inner.h) {
                        ctx.beginPath();
-                       ctx.rect(outer.x, outer.y, outer.w, outer.h);
+                       addRectPath(ctx, outer);
                        ctx.clip();
-                       ctx.rect(inner.x, inner.y, inner.w, inner.h);
+                       addRectPath(ctx, inner);
                        ctx.fillStyle = options.borderColor;
                        ctx.fill('evenodd');
                }
 
+               ctx.beginPath();
+               addRectPath(ctx, inner);
                ctx.fillStyle = options.backgroundColor;
-               ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
+               ctx.fill();
 
                ctx.restore();
        }
@@ -183,7 +255,8 @@ BarElement.id = 'bar';
  */
 BarElement.defaults = {
        borderSkipped: 'start',
-       borderWidth: 0
+       borderWidth: 0,
+       borderRadius: 0
 };
 
 /**
index ba61f5e5249f831ae97087f1f73fecd1788b22bd..980ca15634cb4cb1a243a90cfb9297c0b91ac037 100644 (file)
@@ -64,6 +64,33 @@ export function toTRBL(value) {
        };
 }
 
+/**
+ * Converts the given value into a TRBL corners object (similar with css border-radius).
+ * @param {number|object} value - If a number, set the value to all TRBL corner components,
+ *  else, if an object, use defined properties and sets undefined ones to 0.
+ * @returns {object} The TRBL corner values (topLeft, topRight, bottomLeft, bottomRight)
+ * @since 3.0.0
+ */
+export function toTRBLCorners(value) {
+       let tl, tr, bl, br;
+
+       if (isObject(value)) {
+               tl = numberOrZero(value.topLeft);
+               tr = numberOrZero(value.topRight);
+               bl = numberOrZero(value.bottomLeft);
+               br = numberOrZero(value.bottomRight);
+       } else {
+               tl = tr = bl = br = numberOrZero(value);
+       }
+
+       return {
+               topLeft: tl,
+               topRight: tr,
+               bottomLeft: bl,
+               bottomRight: br
+       };
+}
+
 /**
  * Converts the given value into a padding object with pre-computed width/height.
  * @param {number|object} value - If a number, set the value to all TRBL component,
diff --git a/test/fixtures/controller.bar/border-radius.js b/test/fixtures/controller.bar/border-radius.js
new file mode 100644 (file)
index 0000000..67c579f
--- /dev/null
@@ -0,0 +1,45 @@
+module.exports = {
+       threshold: 0.01,
+       config: {
+               type: 'bar',
+               data: {
+                       labels: [0, 1, 2, 3, 4, 5],
+                       datasets: [
+                               {
+                                       // option in dataset
+                                       data: [0, 5, 10, null, -10, -5],
+                                       borderWidth: 2,
+                                       borderRadius: 5
+                               },
+                               {
+                                       // option in element (fallback)
+                                       data: [0, 5, 10, null, -10, -5],
+                                       borderSkipped: false,
+                                       borderRadius: Number.MAX_VALUE
+                               }
+                       ]
+               },
+               options: {
+                       legend: false,
+                       title: false,
+                       indexAxis: 'y',
+                       elements: {
+                               bar: {
+                                       backgroundColor: '#AAAAAA80',
+                                       borderColor: '#80808080',
+                                       borderWidth: {bottom: 6, left: 15, top: 6, right: 15}
+                               }
+                       },
+                       scales: {
+                               x: {display: false},
+                               y: {display: false}
+                       }
+               }
+       },
+       options: {
+               canvas: {
+                       height: 256,
+                       width: 512
+               }
+       }
+};
diff --git a/test/fixtures/controller.bar/border-radius.png b/test/fixtures/controller.bar/border-radius.png
new file mode 100644 (file)
index 0000000..68e7c0d
Binary files /dev/null and b/test/fixtures/controller.bar/border-radius.png differ
index 1cd6913acfc05dee86e993471c3b7abb9302f706..73adeead5619ec9a0b636a129912c78659d33617 100644 (file)
Binary files a/test/fixtures/controller.bar/horizontal-borders.png and b/test/fixtures/controller.bar/horizontal-borders.png differ
index f742b1bf548353b2510331e12f4818ab6c3c47f9..6bad385c1222d25c06299ae99a55f2927ad7205f 100644 (file)
@@ -1,4 +1,4 @@
-const {toLineHeight, toPadding, toFont, resolve} = Chart.helpers; // from '../../src/helpers/helpers.options';
+const {toLineHeight, toPadding, toFont, resolve, toTRBLCorners} = Chart.helpers; // from '../../src/helpers/helpers.options';
 
 describe('Chart.helpers.options', function() {
        describe('toLineHeight', function() {
@@ -23,6 +23,43 @@ describe('Chart.helpers.options', function() {
                });
        });
 
+       describe('toTRBLCorners', function() {
+               it('should support number values', function() {
+                       expect(toTRBLCorners(4)).toEqual(
+                               {topLeft: 4, topRight: 4, bottomLeft: 4, bottomRight: 4});
+                       expect(toTRBLCorners(4.5)).toEqual(
+                               {topLeft: 4.5, topRight: 4.5, bottomLeft: 4.5, bottomRight: 4.5});
+               });
+               it('should support string values', function() {
+                       expect(toTRBLCorners('4')).toEqual(
+                               {topLeft: 4, topRight: 4, bottomLeft: 4, bottomRight: 4});
+                       expect(toTRBLCorners('4.5')).toEqual(
+                               {topLeft: 4.5, topRight: 4.5, bottomLeft: 4.5, bottomRight: 4.5});
+               });
+               it('should support object values', function() {
+                       expect(toTRBLCorners({topLeft: 1, topRight: 2, bottomLeft: 3, bottomRight: 4})).toEqual(
+                               {topLeft: 1, topRight: 2, bottomLeft: 3, bottomRight: 4});
+                       expect(toTRBLCorners({topLeft: 1.5, topRight: 2.5, bottomLeft: 3.5, bottomRight: 4.5})).toEqual(
+                               {topLeft: 1.5, topRight: 2.5, bottomLeft: 3.5, bottomRight: 4.5});
+                       expect(toTRBLCorners({topLeft: '1', topRight: '2', bottomLeft: '3', bottomRight: '4'})).toEqual(
+                               {topLeft: 1, topRight: 2, bottomLeft: 3, bottomRight: 4});
+               });
+               it('should fallback to 0 for invalid values', function() {
+                       expect(toTRBLCorners({topLeft: 'foo', topRight: 'foo', bottomLeft: 'foo', bottomRight: 'foo'})).toEqual(
+                               {topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0});
+                       expect(toTRBLCorners({topLeft: null, topRight: null, bottomLeft: null, bottomRight: null})).toEqual(
+                               {topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0});
+                       expect(toTRBLCorners({})).toEqual(
+                               {topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0});
+                       expect(toTRBLCorners('foo')).toEqual(
+                               {topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0});
+                       expect(toTRBLCorners(null)).toEqual(
+                               {topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0});
+                       expect(toTRBLCorners(undefined)).toEqual(
+                               {topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0});
+               });
+       });
+
        describe('toPadding', function() {
                it ('should support number values', function() {
                        expect(toPadding(4)).toEqual(
index ce3c200a5172ca6c9a6114148cb94a12b15a4088..2558c4d1c69d1b438da9bef9c7c98af8aba7b34f 100644 (file)
@@ -263,9 +263,24 @@ export interface IBarOptions extends ICommonOptions {
    * @default 'start'
    */
   borderSkipped: 'start' | 'end' | 'left' | 'right' | 'bottom' | 'top';
+
+  /**
+   * Border radius
+   * @default 0
+   */
+  borderRadius: number | IBorderRadius;
 }
 
-export interface IBarHoverOptions extends ICommonHoverOptions {}
+export interface IBorderRadius {
+  topLeft: number;
+  topRight: number;
+  bottomLeft: number;
+  bottomRight: number;
+}
+
+export interface IBarHoverOptions extends ICommonHoverOptions {
+  hoverBorderRadius: number | IBorderRadius;
+}
 
 export interface BarElement<
   T extends IBarProps = IBarProps,