Add borderRadius to bar charts. Closes #7701 (#7951)

* 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
This commit is contained in:
Dan Manastireanu
2020-10-26 16:05:24 +02:00
committed by GitHub
parent 4ed650acbe
commit 495c35950c
11 changed files with 366 additions and 10 deletions

View 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.

View File

@@ -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>

View File

@@ -22,6 +22,9 @@
}, {
title: 'Floating',
path: 'charts/bar/float.html'
}, {
title: 'Border Radius',
path: 'charts/bar/border-radius.html'
}]
}, {
title: 'Line charts',

View File

@@ -521,6 +521,7 @@ BarController.defaults = {
'borderColor',
'borderSkipped',
'borderWidth',
'borderRadius',
'barPercentage',
'barThickness',
'base',

View 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
};
/**

View 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,

View File

@@ -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
}
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View 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(

View 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,