Skip to content

Commit ab79c3b

Browse files
authored
Corrected spacing in pie/doughnut/polarArea (Fixes #10059) (#12238)
* same spacing between pie slieces * introduce spacingMode * some typing issues * updated test fixtures * improvements to self join when combined with spacing or borders * updated docs * tolerances for firefox differences in antialiasing
1 parent a153556 commit ab79c3b

27 files changed

+604
-65
lines changed

docs/charts/doughnut.md

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -100,30 +100,31 @@ Namespaces:
100100

101101
The doughnut/pie chart allows a number of properties to be specified for each dataset. These are used to set display properties for a specific dataset. For example, the colours of the dataset's arcs are generally set this way.
102102

103-
| Name | Type | [Scriptable](../general/options.md#scriptable-options) | [Indexable](../general/options.md#indexable-options) | Default
104-
| ---- | ---- | :----: | :----: | ----
105-
| [`backgroundColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'rgba(0, 0, 0, 0.1)'`
106-
| [`borderAlign`](#border-alignment) | `'center'`\|`'inner'` | Yes | Yes | `'center'`
107-
| [`borderColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'#fff'`
108-
| [`borderDash`](#styling) | `number[]` | Yes | - | `[]`
109-
| [`borderDashOffset`](#styling) | `number` | Yes | - | `0.0`
110-
| [`borderJoinStyle`](#styling) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
111-
| [`borderRadius`](#border-radius) | `number`\|`object` | Yes | Yes | `0`
112-
| [`borderWidth`](#styling) | `number` | Yes | Yes | `2`
113-
| [`circumference`](#general) | `number` | - | - | `undefined`
114-
| [`clip`](#general) | `number`\|`object`\|`false` | - | - | `undefined`
115-
| [`data`](#data-structure) | `number[]` | - | - | **required**
116-
| [`hoverBackgroundColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
117-
| [`hoverBorderColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
118-
| [`hoverBorderDash`](#interactions) | `number[]` | Yes | - | `undefined`
119-
| [`hoverBorderDashOffset`](#interactions) | `number` | Yes | - | `undefined`
120-
| [`hoverBorderJoinStyle`](#interactions) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
121-
| [`hoverBorderWidth`](#interactions) | `number` | Yes | Yes | `undefined`
122-
| [`hoverOffset`](#interactions) | `number` | Yes | Yes | `0`
123-
| [`offset`](#styling) | `number`\|`number[]` | Yes | Yes | `0`
124-
| [`rotation`](#general) | `number` | - | - | `undefined`
125-
| [`spacing`](#styling) | `number` | - | - | `0`
126-
| [`weight`](#styling) | `number` | - | - | `1`
103+
| Name | Type | [Scriptable](../general/options.md#scriptable-options) | [Indexable](../general/options.md#indexable-options) | Default
104+
|------------------------------------------|---------------------------------| :----: | :----: | ----
105+
| [`backgroundColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'rgba(0, 0, 0, 0.1)'`
106+
| [`borderAlign`](#border-alignment) | `'center'`\|`'inner'` | Yes | Yes | `'center'`
107+
| [`borderColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'#fff'`
108+
| [`borderDash`](#styling) | `number[]` | Yes | - | `[]`
109+
| [`borderDashOffset`](#styling) | `number` | Yes | - | `0.0`
110+
| [`borderJoinStyle`](#styling) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
111+
| [`borderRadius`](#border-radius) | `number`\|`object` | Yes | Yes | `0`
112+
| [`borderWidth`](#styling) | `number` | Yes | Yes | `2`
113+
| [`circumference`](#general) | `number` | - | - | `undefined`
114+
| [`clip`](#general) | `number`\|`object`\|`false` | - | - | `undefined`
115+
| [`data`](#data-structure) | `number[]` | - | - | **required**
116+
| [`hoverBackgroundColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
117+
| [`hoverBorderColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
118+
| [`hoverBorderDash`](#interactions) | `number[]` | Yes | - | `undefined`
119+
| [`hoverBorderDashOffset`](#interactions) | `number` | Yes | - | `undefined`
120+
| [`hoverBorderJoinStyle`](#interactions) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
121+
| [`hoverBorderWidth`](#interactions) | `number` | Yes | Yes | `undefined`
122+
| [`hoverOffset`](#interactions) | `number` | Yes | Yes | `0`
123+
| [`offset`](#styling) | `number`\|`number[]` | Yes | Yes | `0`
124+
| [`rotation`](#general) | `number` | - | - | `undefined`
125+
| [`spacing`](#styling) | `number` | - | - | `0`
126+
| [`spacingMode`](#styling) | `angular`\|`proportional`\|`parallel` | - | - | `angular`
127+
| [`weight`](#styling) | `number` | - | - | `1`
127128

128129
All these values, if `undefined`, fallback to the scopes described in [option resolution](../general/options)
129130

docs/charts/polar.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ The following options can be included in a polar area chart dataset to configure
7171
| [`hoverBorderJoinStyle`](#interactions) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
7272
| [`hoverBorderWidth`](#interactions) | `number` | Yes | Yes | `undefined`
7373
| [`circular`](#styling) | `boolean` | Yes | Yes | `true`
74+
| [`spacing`](#styling) | `number` | - | - | `0`
75+
| [`spacingMode`](#styling) | `angular`\|`proportional`\|`parallel` | - | - | `proportional`
7476

7577
All these values, if `undefined`, fallback to the scopes described in [option resolution](../general/options)
7678

src/controllers/controller.doughnut.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,15 @@ export default class DoughnutController extends DatasetController {
7171
// Spacing between arcs
7272
spacing: 0,
7373

74+
// Geometry used to apply spacing between arcs
75+
spacingMode: 'angular',
76+
7477
indexAxis: 'r',
7578
};
7679

7780
static descriptors = {
78-
_scriptable: (name) => name !== 'spacing',
79-
_indexable: (name) => name !== 'spacing' && !name.startsWith('borderDash') && !name.startsWith('hoverBorderDash'),
81+
_scriptable: (name) => name !== 'spacing' && name !== 'spacingMode',
82+
_indexable: (name) => name !== 'spacing' && name !== 'spacingMode' && !name.startsWith('borderDash') && !name.startsWith('hoverBorderDash'),
8083
};
8184

8285
/**

src/controllers/controller.polarArea.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export default class PolarAreaController extends DatasetController {
2222
},
2323
indexAxis: 'r',
2424
startAngle: 0,
25+
spacing: 0,
26+
spacingMode: 'proportional',
2527
};
2628

2729
/**
@@ -199,6 +201,14 @@ export default class PolarAreaController extends DatasetController {
199201
options: this.resolveDataElementOptions(i, arc.active ? 'active' : mode)
200202
};
201203

204+
// Arc defaults (`spacing=0`, `spacingMode='angular'`) can mask polarArea-level values.
205+
if (properties.options.spacing === 0) {
206+
properties.options.spacing = this.options.spacing;
207+
}
208+
if (properties.options.spacingMode === 'angular') {
209+
properties.options.spacingMode = this.options.spacingMode;
210+
}
211+
202212
this.updateElement(arc, i, properties, mode);
203213
}
204214
}

src/elements/element.arc.ts

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ function rThetaToXY(r: number, theta: number, x: number, y: number) {
9898
};
9999
}
100100

101+
function pathFullCircle(
102+
ctx: CanvasRenderingContext2D,
103+
element: ArcElement,
104+
offset: number,
105+
spacing: number,
106+
) {
107+
const {x, y, startAngle: start, pixelMargin, innerRadius: innerR} = element;
108+
const outerRadius = Math.max(element.outerRadius + spacing + offset - pixelMargin, 0);
109+
const innerRadius = innerR > 0 ? innerR + spacing + offset + pixelMargin : 0;
110+
111+
ctx.beginPath();
112+
ctx.arc(x, y, outerRadius, start, start + TAU);
113+
114+
if (innerRadius > 0) {
115+
// Start the inner contour as a separate subpath to avoid a seam connector.
116+
ctx.moveTo(x + Math.cos(start) * innerRadius, y + Math.sin(start) * innerRadius);
117+
ctx.arc(x, y, innerRadius, start + TAU, start, true);
118+
}
119+
120+
ctx.closePath();
121+
}
122+
101123

102124
/**
103125
* Path the arc, respecting border radius by separating into left and right halves.
@@ -122,39 +144,88 @@ function pathArc(
122144
circular: boolean,
123145
) {
124146
const {x, y, startAngle: start, pixelMargin, innerRadius: innerR} = element;
147+
const {spacingMode = 'angular'} = element.options;
148+
const alpha = end - start;
149+
150+
if (circular && element.options.selfJoin && Math.abs(alpha) >= TAU - 1e-4) {
151+
pathFullCircle(ctx, element, offset, spacing);
152+
return;
153+
}
125154

126155
const outerRadius = Math.max(element.outerRadius + spacing + offset - pixelMargin, 0);
127-
const innerRadius = innerR > 0 ? innerR + spacing + offset + pixelMargin : 0;
156+
let innerRadius = innerR > 0 ? innerR + spacing + offset + pixelMargin : 0;
128157

129-
let spacingOffset = 0;
130-
const alpha = end - start;
158+
let outerSpacingOffset = 0;
159+
let innerSpacingOffset = 0;
160+
const beta = outerRadius > 0
161+
? Math.max(0.001, alpha * outerRadius - offset / PI) / outerRadius
162+
: 0.001;
163+
const angleOffset = (alpha - beta) / 2;
131164

132165
if (spacing) {
133166
// When spacing is present, it is the same for all items
134167
// So we adjust the start and end angle of the arc such that
135168
// the distance is the same as it would be without the spacing
136169
const noSpacingInnerRadius = innerR > 0 ? innerR - spacing : 0;
137170
const noSpacingOuterRadius = outerRadius > 0 ? outerRadius - spacing : 0;
138-
const avNogSpacingRadius = (noSpacingInnerRadius + noSpacingOuterRadius) / 2;
139-
const adjustedAngle = avNogSpacingRadius !== 0 ? (alpha * avNogSpacingRadius) / (avNogSpacingRadius + spacing) : alpha;
140-
spacingOffset = (alpha - adjustedAngle) / 2;
171+
const avgNoSpacingRadius = (noSpacingInnerRadius + noSpacingOuterRadius) / 2;
172+
const proportionalOffset = (() => {
173+
const adjustedAngle = avgNoSpacingRadius !== 0 ? (alpha * avgNoSpacingRadius) / (avgNoSpacingRadius + spacing) : alpha;
174+
return (alpha - adjustedAngle) / 2;
175+
})();
176+
const angularOffset = avgNoSpacingRadius > 0 ? Math.asin(Math.min(1, spacing / avgNoSpacingRadius)) : 0;
177+
178+
// Keep spacing trims below half the available span after base offset trimming.
179+
const maxOffset = Math.max(0, beta / 2 - 0.001);
180+
const maxOffsetSin = Math.sin(maxOffset);
181+
182+
if (spacingMode === 'parallel') {
183+
if (innerRadius === 0 && maxOffsetSin > 0) {
184+
// A root radius of zero cannot realize a non-zero parallel separator width.
185+
// Raise the root just enough for the available angular span.
186+
const minInnerRadius = spacing / maxOffsetSin;
187+
const maxInnerRadius = Math.max(0, outerRadius - 0.001);
188+
innerRadius = Math.min(minInnerRadius, maxInnerRadius);
189+
}
190+
191+
// Use one bounded spacing value for both radii so large spacing keeps stable geometry.
192+
const maxParallelSpacing = Math.min(
193+
outerRadius > 0 ? outerRadius * maxOffsetSin : Number.POSITIVE_INFINITY,
194+
innerRadius > 0 ? innerRadius * maxOffsetSin : Number.POSITIVE_INFINITY
195+
);
196+
const parallelSpacing = Math.min(spacing, maxParallelSpacing);
197+
198+
outerSpacingOffset = outerRadius > 0
199+
? Math.asin(Math.min(1, parallelSpacing / outerRadius))
200+
: Math.min(maxOffset, angularOffset);
201+
innerSpacingOffset = innerRadius > 0
202+
? Math.asin(Math.min(1, parallelSpacing / innerRadius))
203+
: outerSpacingOffset;
204+
} else if (spacingMode === 'proportional') {
205+
outerSpacingOffset = Math.min(maxOffset, proportionalOffset);
206+
innerSpacingOffset = Math.min(maxOffset, proportionalOffset);
207+
} else {
208+
outerSpacingOffset = Math.min(maxOffset, angularOffset);
209+
innerSpacingOffset = Math.min(maxOffset, angularOffset);
210+
}
141211
}
142212

143-
const beta = Math.max(0.001, alpha * outerRadius - offset / PI) / outerRadius;
144-
const angleOffset = (alpha - beta) / 2;
145-
const startAngle = start + angleOffset + spacingOffset;
146-
const endAngle = end - angleOffset - spacingOffset;
147-
const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius(element, innerRadius, outerRadius, endAngle - startAngle);
213+
const outerStartAngle = start + angleOffset + outerSpacingOffset;
214+
const outerEndAngle = end - angleOffset - outerSpacingOffset;
215+
const innerStartAngle = start + angleOffset + innerSpacingOffset;
216+
const innerEndAngle = end - angleOffset - innerSpacingOffset;
217+
const angleDelta = Math.min(outerEndAngle - outerStartAngle, innerEndAngle - innerStartAngle);
218+
const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius(element, innerRadius, outerRadius, angleDelta);
148219

149220
const outerStartAdjustedRadius = outerRadius - outerStart;
150221
const outerEndAdjustedRadius = outerRadius - outerEnd;
151-
const outerStartAdjustedAngle = startAngle + outerStart / outerStartAdjustedRadius;
152-
const outerEndAdjustedAngle = endAngle - outerEnd / outerEndAdjustedRadius;
222+
const outerStartAdjustedAngle = outerStartAngle + outerStart / outerStartAdjustedRadius;
223+
const outerEndAdjustedAngle = outerEndAngle - outerEnd / outerEndAdjustedRadius;
153224

154225
const innerStartAdjustedRadius = innerRadius + innerStart;
155226
const innerEndAdjustedRadius = innerRadius + innerEnd;
156-
const innerStartAdjustedAngle = startAngle + innerStart / innerStartAdjustedRadius;
157-
const innerEndAdjustedAngle = endAngle - innerEnd / innerEndAdjustedRadius;
227+
const innerStartAdjustedAngle = innerStartAngle + innerStart / innerStartAdjustedRadius;
228+
const innerEndAdjustedAngle = innerEndAngle - innerEnd / innerEndAdjustedRadius;
158229

159230
ctx.beginPath();
160231

@@ -167,38 +238,38 @@ function pathArc(
167238
// The corner segment from point 2 to point 3
168239
if (outerEnd > 0) {
169240
const pCenter = rThetaToXY(outerEndAdjustedRadius, outerEndAdjustedAngle, x, y);
170-
ctx.arc(pCenter.x, pCenter.y, outerEnd, outerEndAdjustedAngle, endAngle + HALF_PI);
241+
ctx.arc(pCenter.x, pCenter.y, outerEnd, outerEndAdjustedAngle, outerEndAngle + HALF_PI);
171242
}
172243

173244
// The line from point 3 to point 4
174-
const p4 = rThetaToXY(innerEndAdjustedRadius, endAngle, x, y);
245+
const p4 = rThetaToXY(innerEndAdjustedRadius, innerEndAngle, x, y);
175246
ctx.lineTo(p4.x, p4.y);
176247

177248
// The corner segment from point 4 to point 5
178249
if (innerEnd > 0) {
179250
const pCenter = rThetaToXY(innerEndAdjustedRadius, innerEndAdjustedAngle, x, y);
180-
ctx.arc(pCenter.x, pCenter.y, innerEnd, endAngle + HALF_PI, innerEndAdjustedAngle + Math.PI);
251+
ctx.arc(pCenter.x, pCenter.y, innerEnd, innerEndAngle + HALF_PI, innerEndAdjustedAngle + Math.PI);
181252
}
182253

183254
// The inner arc from point 5 to point b to point 6
184-
const innerMidAdjustedAngle = ((endAngle - (innerEnd / innerRadius)) + (startAngle + (innerStart / innerRadius))) / 2;
185-
ctx.arc(x, y, innerRadius, endAngle - (innerEnd / innerRadius), innerMidAdjustedAngle, true);
186-
ctx.arc(x, y, innerRadius, innerMidAdjustedAngle, startAngle + (innerStart / innerRadius), true);
255+
const innerMidAdjustedAngle = ((innerEndAngle - (innerEnd / innerRadius)) + (innerStartAngle + (innerStart / innerRadius))) / 2;
256+
ctx.arc(x, y, innerRadius, innerEndAngle - (innerEnd / innerRadius), innerMidAdjustedAngle, true);
257+
ctx.arc(x, y, innerRadius, innerMidAdjustedAngle, innerStartAngle + (innerStart / innerRadius), true);
187258

188259
// The corner segment from point 6 to point 7
189260
if (innerStart > 0) {
190261
const pCenter = rThetaToXY(innerStartAdjustedRadius, innerStartAdjustedAngle, x, y);
191-
ctx.arc(pCenter.x, pCenter.y, innerStart, innerStartAdjustedAngle + Math.PI, startAngle - HALF_PI);
262+
ctx.arc(pCenter.x, pCenter.y, innerStart, innerStartAdjustedAngle + Math.PI, innerStartAngle - HALF_PI);
192263
}
193264

194265
// The line from point 7 to point 8
195-
const p8 = rThetaToXY(outerStartAdjustedRadius, startAngle, x, y);
266+
const p8 = rThetaToXY(outerStartAdjustedRadius, outerStartAngle, x, y);
196267
ctx.lineTo(p8.x, p8.y);
197268

198269
// The corner segment from point 8 to point 1
199270
if (outerStart > 0) {
200271
const pCenter = rThetaToXY(outerStartAdjustedRadius, outerStartAdjustedAngle, x, y);
201-
ctx.arc(pCenter.x, pCenter.y, outerStart, startAngle - HALF_PI, outerStartAdjustedAngle);
272+
ctx.arc(pCenter.x, pCenter.y, outerStart, outerStartAngle - HALF_PI, outerStartAdjustedAngle);
202273
}
203274
} else {
204275
ctx.moveTo(x, y);
@@ -265,6 +336,7 @@ function drawBorder(
265336
}
266337

267338
let endAngle = element.endAngle;
339+
const isFullCircle = Math.abs(endAngle - startAngle) >= TAU - 1e-4;
268340
if (fullCircles) {
269341
pathArc(ctx, element, offset, spacing, endAngle, circular);
270342
for (let i = 0; i < fullCircles; ++i) {
@@ -279,7 +351,8 @@ function drawBorder(
279351
clipArc(ctx, element, endAngle);
280352
}
281353

282-
if (options.selfJoin && endAngle - startAngle >= PI && borderRadius === 0 && borderJoinStyle !== 'miter') {
354+
const skipSelfClip = isFullCircle && element.innerRadius > 0;
355+
if (!skipSelfClip && options.selfJoin && endAngle - startAngle >= PI && borderRadius === 0 && borderJoinStyle !== 'miter') {
283356
clipSelf(ctx, element, endAngle);
284357
}
285358

@@ -311,6 +384,7 @@ export default class ArcElement extends Element<ArcProps, ArcOptions> {
311384
borderWidth: 2,
312385
offset: 0,
313386
spacing: 0,
387+
spacingMode: 'angular',
314388
angle: undefined,
315389
circular: true,
316390
selfJoin: false,
@@ -332,6 +406,7 @@ export default class ArcElement extends Element<ArcProps, ArcOptions> {
332406
outerRadius: number;
333407
pixelMargin: number;
334408
startAngle: number;
409+
circular: boolean;
335410

336411
constructor(cfg) {
337412
super();
@@ -344,6 +419,7 @@ export default class ArcElement extends Element<ArcProps, ArcOptions> {
344419
this.outerRadius = undefined;
345420
this.pixelMargin = 0;
346421
this.fullCircles = 0;
422+
this.circular = false;
347423

348424
if (cfg) {
349425
Object.assign(this, cfg);

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export {
4949
ElementOptionsByType,
5050
ChartDatasetProperties,
5151
UpdateModeEnum,
52+
ArcSpacingMode,
5253
registerables
5354
} from './types/index.js';
5455
export * from './types/index.js';

0 commit comments

Comments
 (0)