diff --git a/docs/charts/doughnut.md b/docs/charts/doughnut.md index a4f36794f85..1c1755e9297 100644 --- a/docs/charts/doughnut.md +++ b/docs/charts/doughnut.md @@ -116,6 +116,7 @@ The doughnut/pie chart allows a number of properties to be specified for each da | [`hoverOffset`](#interactions) | `number` | Yes | Yes | `0` | [`offset`](#styling) | `number` | Yes | Yes | `0` | [`rotation`](#general) | `number` | - | - | `undefined` +| [`spacing](#styling) | `number` | - | - | `0` | [`weight`](#styling) | `number` | - | - | `1` All these values, if `undefined`, fallback to the scopes described in [option resolution](../general/options) @@ -138,6 +139,7 @@ The style of each arc can be controlled with the following properties: | `borderColor` | arc border color. | `borderWidth` | arc border width (in pixels). | `offset` | arc offset (in pixels). +| `spacing` | Fixed arc offset (in pixels). Similar to `offset` but applies to all arcs. | `weight` | The relative thickness of the dataset. Providing a value for weight will cause the pie or doughnut dataset to be drawn with a thickness relative to the sum of all the dataset weight values. All these values, if `undefined`, fallback to the associated [`elements.arc.*`](../configuration/elements.md#arc-configuration) options. diff --git a/src/controllers/controller.doughnut.js b/src/controllers/controller.doughnut.js index d6c9c020b80..bf472317c07 100644 --- a/src/controllers/controller.doughnut.js +++ b/src/controllers/controller.doughnut.js @@ -110,7 +110,7 @@ export default class DoughnutController extends DatasetController { const {chartArea} = chart; const meta = me._cachedMeta; const arcs = meta.data; - const spacing = me.getMaxBorderWidth() + me.getMaxOffset(arcs); + const spacing = me.getMaxBorderWidth() + me.getMaxOffset(arcs) + me.options.spacing; const maxSize = Math.max((Math.min(chartArea.width, chartArea.height) - spacing) / 2, 0); const cutout = Math.min(toPercentage(me.options.cutout, maxSize), 1); const chartWeight = me._getRingWeight(me.index); @@ -325,7 +325,7 @@ DoughnutController.defaults = { animations: { numbers: { type: 'number', - properties: ['circumference', 'endAngle', 'innerRadius', 'outerRadius', 'startAngle', 'x', 'y', 'offset', 'borderWidth'] + properties: ['circumference', 'endAngle', 'innerRadius', 'outerRadius', 'startAngle', 'x', 'y', 'offset', 'borderWidth', 'spacing'] }, }, // The percentage of the chart that we cut out of the middle. @@ -340,9 +340,17 @@ DoughnutController.defaults = { // The outr radius of the chart radius: '100%', + // Spacing between arcs + spacing: 0, + indexAxis: 'r', }; +DoughnutController.descriptors = { + _scriptable: (name) => name !== 'spacing', + _indexable: (name) => name !== 'spacing', +}; + /** * @type {any} */ diff --git a/src/elements/element.arc.js b/src/elements/element.arc.js index c283b577a8e..918128cdffc 100644 --- a/src/elements/element.arc.js +++ b/src/elements/element.arc.js @@ -93,16 +93,30 @@ function rThetaToXY(r, theta, x, y) { * @param {CanvasRenderingContext2D} ctx * @param {ArcElement} element */ -function pathArc(ctx, element, offset, end) { +function pathArc(ctx, element, offset, spacing, end) { const {x, y, startAngle: start, pixelMargin, innerRadius: innerR} = element; - const outerRadius = Math.max(element.outerRadius + offset - pixelMargin, 0); - const innerRadius = innerR > 0 ? innerR + offset + pixelMargin : 0; + const outerRadius = Math.max(element.outerRadius + spacing + offset - pixelMargin, 0); + const innerRadius = innerR > 0 ? innerR + spacing + offset + pixelMargin : 0; + + let spacingOffset = 0; const alpha = end - start; + + if (spacing) { + // When spacing is present, it is the same for all items + // So we adjust the start and end angle of the arc such that + // the distance is the same as it would be without the spacing + const noSpacingInnerRadius = innerR > 0 ? innerR - spacing : 0; + const noSpacingOuterRadius = outerRadius > 0 ? outerRadius - spacing : 0; + const avNogSpacingRadius = (noSpacingInnerRadius + noSpacingOuterRadius) / 2; + const adjustedAngle = avNogSpacingRadius !== 0 ? (alpha * avNogSpacingRadius) / (avNogSpacingRadius + spacing) : alpha; + spacingOffset = (alpha - adjustedAngle) / 2; + } + const beta = Math.max(0.001, alpha * outerRadius - offset / PI) / outerRadius; const angleOffset = (alpha - beta) / 2; - const startAngle = start + angleOffset; - const endAngle = end - angleOffset; + const startAngle = start + angleOffset + spacingOffset; + const endAngle = end - angleOffset - spacingOffset; const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius(element, innerRadius, outerRadius, endAngle - startAngle); const outerStartAdjustedRadius = outerRadius - outerStart; @@ -158,11 +172,11 @@ function pathArc(ctx, element, offset, end) { ctx.closePath(); } -function drawArc(ctx, element, offset) { +function drawArc(ctx, element, offset, spacing) { const {fullCircles, startAngle, circumference} = element; let endAngle = element.endAngle; if (fullCircles) { - pathArc(ctx, element, offset, startAngle + TAU); + pathArc(ctx, element, offset, spacing, startAngle + TAU); for (let i = 0; i < fullCircles; ++i) { ctx.fill(); @@ -176,7 +190,7 @@ function drawArc(ctx, element, offset) { } } - pathArc(ctx, element, offset, endAngle); + pathArc(ctx, element, offset, spacing, endAngle); ctx.fill(); return endAngle; } @@ -205,7 +219,7 @@ function drawFullCircleBorders(ctx, element, inner) { } } -function drawBorder(ctx, element, offset, endAngle) { +function drawBorder(ctx, element, offset, spacing, endAngle) { const {options} = element; const inner = options.borderAlign === 'inner'; @@ -229,7 +243,7 @@ function drawBorder(ctx, element, offset, endAngle) { clipArc(ctx, element, endAngle); } - pathArc(ctx, element, offset, endAngle); + pathArc(ctx, element, offset, spacing, endAngle); ctx.stroke(); } @@ -267,8 +281,9 @@ export default class ArcElement extends Element { 'outerRadius', 'circumference' ], useFinalPosition); + const rAdjust = this.options.spacing / 2; const betweenAngles = circumference >= TAU || _angleBetween(angle, startAngle, endAngle); - const withinRadius = (distance >= innerRadius && distance <= outerRadius); + const withinRadius = (distance >= innerRadius + rAdjust && distance <= outerRadius + rAdjust); return (betweenAngles && withinRadius); } @@ -284,10 +299,11 @@ export default class ArcElement extends Element { 'endAngle', 'innerRadius', 'outerRadius', - 'circumference' + 'circumference', ], useFinalPosition); + const {offset, spacing} = this.options; const halfAngle = (startAngle + endAngle) / 2; - const halfRadius = (innerRadius + outerRadius) / 2; + const halfRadius = (innerRadius + outerRadius + spacing + offset) / 2; return { x: x + Math.cos(halfAngle) * halfRadius, y: y + Math.sin(halfAngle) * halfRadius @@ -305,6 +321,7 @@ export default class ArcElement extends Element { const me = this; const {options, circumference} = me; const offset = (options.offset || 0) / 2; + const spacing = (options.spacing || 0) / 2; me.pixelMargin = (options.borderAlign === 'inner') ? 0.33 : 0; me.fullCircles = circumference > TAU ? Math.floor(circumference / TAU) : 0; @@ -327,8 +344,8 @@ export default class ArcElement extends Element { ctx.fillStyle = options.backgroundColor; ctx.strokeStyle = options.borderColor; - const endAngle = drawArc(ctx, me, radiusOffset); - drawBorder(ctx, me, radiusOffset, endAngle); + const endAngle = drawArc(ctx, me, radiusOffset, spacing); + drawBorder(ctx, me, radiusOffset, spacing, endAngle); ctx.restore(); } @@ -345,6 +362,7 @@ ArcElement.defaults = { borderRadius: 0, borderWidth: 2, offset: 0, + spacing: 0, angle: undefined, }; diff --git a/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.js b/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.js new file mode 100644 index 00000000000..d2e0c59b07a --- /dev/null +++ b/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.js @@ -0,0 +1,29 @@ +module.exports = { + config: { + type: 'doughnut', + data: { + datasets: [{ + data: [10, 20, 40, 50, 5], + label: 'Dataset 1', + backgroundColor: [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue' + ] + }], + labels: [ + 'Item 1', + 'Item 2', + 'Item 3', + 'Item 4', + 'Item 5' + ], + }, + options: { + spacing: 50, + offset: [0, 50, 0, 0, 0], + } + } +}; diff --git a/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.png b/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.png new file mode 100644 index 00000000000..e78e313ddc0 Binary files /dev/null and b/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.png differ diff --git a/test/fixtures/controller.doughnut/doughnut-spacing.js b/test/fixtures/controller.doughnut/doughnut-spacing.js new file mode 100644 index 00000000000..c6c84379231 --- /dev/null +++ b/test/fixtures/controller.doughnut/doughnut-spacing.js @@ -0,0 +1,28 @@ +module.exports = { + config: { + type: 'doughnut', + data: { + datasets: [{ + data: [10, 20, 40, 50, 5], + label: 'Dataset 1', + backgroundColor: [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue' + ] + }], + labels: [ + 'Item 1', + 'Item 2', + 'Item 3', + 'Item 4', + 'Item 5' + ], + }, + options: { + spacing: 50, + } + } +}; diff --git a/test/fixtures/controller.doughnut/doughnut-spacing.png b/test/fixtures/controller.doughnut/doughnut-spacing.png new file mode 100644 index 00000000000..d586621e980 Binary files /dev/null and b/test/fixtures/controller.doughnut/doughnut-spacing.png differ diff --git a/test/specs/element.arc.tests.js b/test/specs/element.arc.tests.js index e857b97dc10..23380aa250d 100644 --- a/test/specs/element.arc.tests.js +++ b/test/specs/element.arc.tests.js @@ -10,6 +10,10 @@ describe('Arc element tests', function() { y: 0, innerRadius: 5, outerRadius: 10, + options: { + spacing: 0, + offset: 0, + } }); expect(arc.inRange(2, 2)).toBe(false); @@ -19,6 +23,25 @@ describe('Arc element tests', function() { expect(arc.inRange(-1.0 * Math.sqrt(7), Math.sqrt(7))).toBe(false); }); + it ('should include spacing for in range check', function() { + // Mock out the arc as if the controller put it there + var arc = new Chart.elements.ArcElement({ + startAngle: 0, + endAngle: Math.PI / 2, + x: 0, + y: 0, + innerRadius: 5, + outerRadius: 10, + options: { + spacing: 10, + offset: 0, + } + }); + + expect(arc.inRange(7, 0)).toBe(false); + expect(arc.inRange(15, 0)).toBe(true); + }); + it ('should determine if in range, when full circle', function() { // Mock out the arc as if the controller put it there var arc = new Chart.elements.ArcElement({ @@ -28,7 +51,11 @@ describe('Arc element tests', function() { y: 0, innerRadius: 0, outerRadius: 10, - circumference: Math.PI * 2 + circumference: Math.PI * 2, + options: { + spacing: 0, + offset: 0, + } }); expect(arc.inRange(7, 7)).toBe(true); @@ -43,6 +70,10 @@ describe('Arc element tests', function() { y: 0, innerRadius: 0, outerRadius: Math.sqrt(2), + options: { + spacing: 0, + offset: 0, + } }); var pos = arc.tooltipPosition(); @@ -59,6 +90,10 @@ describe('Arc element tests', function() { y: 0, innerRadius: 0, outerRadius: Math.sqrt(2), + options: { + spacing: 0, + offset: 0, + } }); var center = arc.getCenterPoint(); @@ -66,6 +101,26 @@ describe('Arc element tests', function() { expect(center.y).toBeCloseTo(0.5, 6); }); + it ('should get the center with offset and spacing', function() { + // Mock out the arc as if the controller put it there + var arc = new Chart.elements.ArcElement({ + startAngle: 0, + endAngle: Math.PI / 2, + x: 0, + y: 0, + innerRadius: 0, + outerRadius: Math.sqrt(2), + options: { + spacing: 10, + offset: 10, + } + }); + + var center = arc.getCenterPoint(); + expect(center.x).toBeCloseTo(7.57, 1); + expect(center.y).toBeCloseTo(7.57, 1); + }); + it ('should get the center of full circle before and after draw', function() { // Mock out the arc as if the controller put it there var arc = new Chart.elements.ArcElement({ @@ -75,7 +130,10 @@ describe('Arc element tests', function() { y: 2, innerRadius: 0, outerRadius: 2, - options: {} + options: { + spacing: 0, + offset: 0, + } }); var center = arc.getCenterPoint(); @@ -100,7 +158,10 @@ describe('Arc element tests', function() { y: 0, innerRadius: -0.1, outerRadius: Math.sqrt(2), - options: {} + options: { + spacing: 0, + offset: 0, + } }); arc.draw(ctx); @@ -114,7 +175,10 @@ describe('Arc element tests', function() { y: 0, innerRadius: 0, outerRadius: -1, - options: {} + options: { + spacing: 0, + offset: 0, + } }); arc.draw(ctx); diff --git a/types/index.esm.d.ts b/types/index.esm.d.ts index 17b06d78d13..0821862172b 100644 --- a/types/index.esm.d.ts +++ b/types/index.esm.d.ts @@ -252,6 +252,13 @@ export interface DoughnutControllerDatasetOptions * @default 1 */ weight: number; + + /** + * Similar to the `offset` option, but applies to all arcs. This can be used to to add spaces + * between arcs + * @default 0 + */ + spacing: number; } export interface DoughnutAnimationOptions { @@ -294,6 +301,12 @@ export interface DoughnutControllerChartOptions { */ rotation: number; + /** + * Spacing between the arcs + * @default 0 + */ + spacing: number; + animation: DoughnutAnimationOptions; }