Skip to content

Commit f934bd7

Browse files
committed
introduce spacingMode
1 parent 7a59f10 commit f934bd7

7 files changed

Lines changed: 270 additions & 51 deletions

File tree

src/controllers/controller.doughnut.js

Lines changed: 6 additions & 4 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
/**
@@ -279,8 +282,7 @@ export default class DoughnutController extends DatasetController {
279282
endAngle: startAngle + circumference,
280283
circumference,
281284
outerRadius,
282-
innerRadius,
283-
circular: false
285+
innerRadius
284286
};
285287
if (includeOptions) {
286288
properties.options = sharedOptions || this.resolveDataElementOptions(i, arc.active ? 'active' : mode);

src/controllers/controller.polarArea.js

Lines changed: 10 additions & 1 deletion
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
/**
@@ -196,10 +198,17 @@ export default class PolarAreaController extends DatasetController {
196198
outerRadius,
197199
startAngle,
198200
endAngle,
199-
circular: true,
200201
options: this.resolveDataElementOptions(i, arc.active ? 'active' : mode)
201202
};
202203

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+
203212
this.updateElement(arc, i, properties, mode);
204213
}
205214
}

src/elements/element.arc.ts

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,18 @@ function pathArc(
122122
circular: boolean,
123123
) {
124124
const {x, y, startAngle: start, pixelMargin, innerRadius: innerR} = element;
125+
const {spacingMode = 'angular'} = element.options;
125126

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

129-
let spacingOffset = 0;
130+
let outerSpacingOffset = 0;
131+
let innerSpacingOffset = 0;
130132
const alpha = end - start;
133+
const beta = outerRadius > 0
134+
? Math.max(0.001, alpha * outerRadius - offset / PI) / outerRadius
135+
: 0.001;
136+
const angleOffset = (alpha - beta) / 2;
131137

132138
if (spacing) {
133139
// When spacing is present, it is the same for all items
@@ -136,29 +142,63 @@ function pathArc(
136142
const noSpacingInnerRadius = innerR > 0 ? innerR - spacing : 0;
137143
const noSpacingOuterRadius = outerRadius > 0 ? outerRadius - spacing : 0;
138144
const avgNoSpacingRadius = (noSpacingInnerRadius + noSpacingOuterRadius) / 2;
139-
if (circular && avgNoSpacingRadius > 0) {
140-
spacingOffset = Math.asin(Math.min(1, spacing / avgNoSpacingRadius));
141-
} else {
145+
const proportionalOffset = (() => {
142146
const adjustedAngle = avgNoSpacingRadius !== 0 ? (alpha * avgNoSpacingRadius) / (avgNoSpacingRadius + spacing) : alpha;
143-
spacingOffset = (alpha - adjustedAngle) / 2;
147+
return (alpha - adjustedAngle) / 2;
148+
})();
149+
const angularOffset = avgNoSpacingRadius > 0 ? Math.asin(Math.min(1, spacing / avgNoSpacingRadius)) : 0;
150+
151+
// Keep spacing trims below half the available span after base offset trimming.
152+
const maxOffset = Math.max(0, beta / 2 - 0.001);
153+
const maxOffsetSin = Math.sin(maxOffset);
154+
155+
if (spacingMode === 'parallel') {
156+
if (innerRadius === 0 && maxOffsetSin > 0) {
157+
// A root radius of zero cannot realize a non-zero parallel separator width.
158+
// Raise the root just enough for the available angular span.
159+
const minInnerRadius = spacing / maxOffsetSin;
160+
const maxInnerRadius = Math.max(0, outerRadius - 0.001);
161+
innerRadius = Math.min(minInnerRadius, maxInnerRadius);
162+
}
163+
164+
// Use one bounded spacing value for both radii so large spacing keeps stable geometry.
165+
const maxParallelSpacing = Math.min(
166+
outerRadius > 0 ? outerRadius * maxOffsetSin : Number.POSITIVE_INFINITY,
167+
innerRadius > 0 ? innerRadius * maxOffsetSin : Number.POSITIVE_INFINITY
168+
);
169+
const parallelSpacing = Math.min(spacing, maxParallelSpacing);
170+
171+
outerSpacingOffset = outerRadius > 0
172+
? Math.asin(Math.min(1, parallelSpacing / outerRadius))
173+
: Math.min(maxOffset, angularOffset);
174+
innerSpacingOffset = innerRadius > 0
175+
? Math.asin(Math.min(1, parallelSpacing / innerRadius))
176+
: outerSpacingOffset;
177+
} else if (spacingMode === 'proportional') {
178+
outerSpacingOffset = Math.min(maxOffset, proportionalOffset);
179+
innerSpacingOffset = Math.min(maxOffset, proportionalOffset);
180+
} else {
181+
outerSpacingOffset = Math.min(maxOffset, angularOffset);
182+
innerSpacingOffset = Math.min(maxOffset, angularOffset);
144183
}
145184
}
146185

147-
const beta = Math.max(0.001, alpha * outerRadius - offset / PI) / outerRadius;
148-
const angleOffset = (alpha - beta) / 2;
149-
const startAngle = start + angleOffset + spacingOffset;
150-
const endAngle = end - angleOffset - spacingOffset;
151-
const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius(element, innerRadius, outerRadius, endAngle - startAngle);
186+
const outerStartAngle = start + angleOffset + outerSpacingOffset;
187+
const outerEndAngle = end - angleOffset - outerSpacingOffset;
188+
const innerStartAngle = start + angleOffset + innerSpacingOffset;
189+
const innerEndAngle = end - angleOffset - innerSpacingOffset;
190+
const angleDelta = Math.min(outerEndAngle - outerStartAngle, innerEndAngle - innerStartAngle);
191+
const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius(element, innerRadius, outerRadius, angleDelta);
152192

153193
const outerStartAdjustedRadius = outerRadius - outerStart;
154194
const outerEndAdjustedRadius = outerRadius - outerEnd;
155-
const outerStartAdjustedAngle = startAngle + outerStart / outerStartAdjustedRadius;
156-
const outerEndAdjustedAngle = endAngle - outerEnd / outerEndAdjustedRadius;
195+
const outerStartAdjustedAngle = outerStartAngle + outerStart / outerStartAdjustedRadius;
196+
const outerEndAdjustedAngle = outerEndAngle - outerEnd / outerEndAdjustedRadius;
157197

158198
const innerStartAdjustedRadius = innerRadius + innerStart;
159199
const innerEndAdjustedRadius = innerRadius + innerEnd;
160-
const innerStartAdjustedAngle = startAngle + innerStart / innerStartAdjustedRadius;
161-
const innerEndAdjustedAngle = endAngle - innerEnd / innerEndAdjustedRadius;
200+
const innerStartAdjustedAngle = innerStartAngle + innerStart / innerStartAdjustedRadius;
201+
const innerEndAdjustedAngle = innerEndAngle - innerEnd / innerEndAdjustedRadius;
162202

163203
ctx.beginPath();
164204

@@ -171,38 +211,38 @@ function pathArc(
171211
// The corner segment from point 2 to point 3
172212
if (outerEnd > 0) {
173213
const pCenter = rThetaToXY(outerEndAdjustedRadius, outerEndAdjustedAngle, x, y);
174-
ctx.arc(pCenter.x, pCenter.y, outerEnd, outerEndAdjustedAngle, endAngle + HALF_PI);
214+
ctx.arc(pCenter.x, pCenter.y, outerEnd, outerEndAdjustedAngle, outerEndAngle + HALF_PI);
175215
}
176216

177217
// The line from point 3 to point 4
178-
const p4 = rThetaToXY(innerEndAdjustedRadius, endAngle, x, y);
218+
const p4 = rThetaToXY(innerEndAdjustedRadius, innerEndAngle, x, y);
179219
ctx.lineTo(p4.x, p4.y);
180220

181221
// The corner segment from point 4 to point 5
182222
if (innerEnd > 0) {
183223
const pCenter = rThetaToXY(innerEndAdjustedRadius, innerEndAdjustedAngle, x, y);
184-
ctx.arc(pCenter.x, pCenter.y, innerEnd, endAngle + HALF_PI, innerEndAdjustedAngle + Math.PI);
224+
ctx.arc(pCenter.x, pCenter.y, innerEnd, innerEndAngle + HALF_PI, innerEndAdjustedAngle + Math.PI);
185225
}
186226

187227
// The inner arc from point 5 to point b to point 6
188-
const innerMidAdjustedAngle = ((endAngle - (innerEnd / innerRadius)) + (startAngle + (innerStart / innerRadius))) / 2;
189-
ctx.arc(x, y, innerRadius, endAngle - (innerEnd / innerRadius), innerMidAdjustedAngle, true);
190-
ctx.arc(x, y, innerRadius, innerMidAdjustedAngle, startAngle + (innerStart / innerRadius), true);
228+
const innerMidAdjustedAngle = ((innerEndAngle - (innerEnd / innerRadius)) + (innerStartAngle + (innerStart / innerRadius))) / 2;
229+
ctx.arc(x, y, innerRadius, innerEndAngle - (innerEnd / innerRadius), innerMidAdjustedAngle, true);
230+
ctx.arc(x, y, innerRadius, innerMidAdjustedAngle, innerStartAngle + (innerStart / innerRadius), true);
191231

192232
// The corner segment from point 6 to point 7
193233
if (innerStart > 0) {
194234
const pCenter = rThetaToXY(innerStartAdjustedRadius, innerStartAdjustedAngle, x, y);
195-
ctx.arc(pCenter.x, pCenter.y, innerStart, innerStartAdjustedAngle + Math.PI, startAngle - HALF_PI);
235+
ctx.arc(pCenter.x, pCenter.y, innerStart, innerStartAdjustedAngle + Math.PI, innerStartAngle - HALF_PI);
196236
}
197237

198238
// The line from point 7 to point 8
199-
const p8 = rThetaToXY(outerStartAdjustedRadius, startAngle, x, y);
239+
const p8 = rThetaToXY(outerStartAdjustedRadius, outerStartAngle, x, y);
200240
ctx.lineTo(p8.x, p8.y);
201241

202242
// The corner segment from point 8 to point 1
203243
if (outerStart > 0) {
204244
const pCenter = rThetaToXY(outerStartAdjustedRadius, outerStartAdjustedAngle, x, y);
205-
ctx.arc(pCenter.x, pCenter.y, outerStart, startAngle - HALF_PI, outerStartAdjustedAngle);
245+
ctx.arc(pCenter.x, pCenter.y, outerStart, outerStartAngle - HALF_PI, outerStartAdjustedAngle);
206246
}
207247
} else {
208248
ctx.moveTo(x, y);
@@ -315,6 +355,7 @@ export default class ArcElement extends Element<ArcProps, ArcOptions> {
315355
borderWidth: 2,
316356
offset: 0,
317357
spacing: 0,
358+
spacingMode: 'angular',
318359
angle: undefined,
319360
circular: true,
320361
selfJoin: false,

src/types/index.d.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,20 @@ export interface DoughnutControllerDatasetOptions
274274
weight: number;
275275

276276
/**
277-
* Similar to the `offset` option, but applies to all arcs. This can be used to to add spaces
278-
* between arcs
279-
* @default 0
280-
*/
277+
* Similar to the `offset` option, but applies to all arcs. This can be used to to add spaces
278+
* between arcs
279+
* @default 0
280+
*/
281281
spacing: number;
282+
283+
/**
284+
* Geometry used to apply arc spacing.
285+
* - `proportional`: legacy behavior (default for polarArea).
286+
* - `angular`: constant angular trim (default for doughnut/pie).
287+
* - `parallel`: separate inner/outer trims for constant-width separators (best for small-moderate spacings).
288+
* @default 'angular'
289+
*/
290+
spacingMode: ArcSpacingMode;
282291
}
283292

284293
export interface DoughnutAnimationOptions extends AnimationSpec<'doughnut'> {
@@ -327,11 +336,20 @@ export interface DoughnutControllerChartOptions {
327336
rotation: number;
328337

329338
/**
330-
* Spacing between the arcs
331-
* @default 0
332-
*/
339+
* Spacing between the arcs
340+
* @default 0
341+
*/
333342
spacing: number;
334343

344+
/**
345+
* Geometry used to apply arc spacing.
346+
* - `proportional`: legacy behavior (default for polarArea).
347+
* - `angular`: constant angular trim (default for doughnut/pie).
348+
* - `parallel`: separate inner/outer trims for constant-width separators (best for small-moderate spacings).
349+
* @default 'angular'
350+
*/
351+
spacingMode: ArcSpacingMode;
352+
335353
animation: false | DoughnutAnimationOptions;
336354
}
337355

@@ -386,6 +404,18 @@ export interface PolarAreaControllerChartOptions {
386404
*/
387405
startAngle: number;
388406

407+
/**
408+
* Spacing between the arcs
409+
* @default 0
410+
*/
411+
spacing: number;
412+
413+
/**
414+
* Geometry used to apply arc spacing.
415+
* @default 'proportional'
416+
*/
417+
spacingMode: ArcSpacingMode;
418+
389419
animation: false | PolarAreaAnimationOptions;
390420
}
391421

@@ -1847,6 +1877,8 @@ export interface ArcBorderRadius {
18471877
innerEnd: number;
18481878
}
18491879

1880+
export type ArcSpacingMode = 'proportional' | 'angular' | 'parallel';
1881+
18501882
export interface ArcOptions extends CommonElementOptions {
18511883
/**
18521884
* If true, Arc can take up 100% of a circular graph without any visual split or cut. This option doesn't support borderRadius and borderJoinStyle miter
@@ -1893,7 +1925,20 @@ export interface ArcOptions extends CommonElementOptions {
18931925
/**
18941926
* Spacing between arcs
18951927
*/
1896-
spacing: number
1928+
spacing: number;
1929+
1930+
/**
1931+
* Geometry used to apply arc spacing. Only applies to chart types with arc elements (doughnut, pie, polarArea).
1932+
* Radar charts use line elements and do not support arc spacing modes.
1933+
*
1934+
* - `proportional`: keeps the legacy proportional spacing behavior.
1935+
* - `angular`: applies a constant angular trim based on average radius.
1936+
* - `parallel`: applies separate inner/outer trims to keep separator width constant.
1937+
* Works best for small to moderate spacings. For very large spacings, the offset is capped
1938+
* to prevent arc angle reversals.
1939+
* @default 'angular'
1940+
*/
1941+
spacingMode: ArcSpacingMode;
18971942
}
18981943

18991944
export interface ArcHoverOptions extends CommonHoverOptions {

test/specs/controller.doughnut.tests.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe('Chart.controllers.doughnut', function() {
4646
expect(meta.data[3] instanceof Chart.elements.ArcElement).toBe(true);
4747
});
4848

49-
it('should mark arcs as non-circular', function() {
49+
it('should default arc spacingMode to angular', function() {
5050
var chart = window.acquireChart({
5151
type: 'doughnut',
5252
data: {
@@ -58,8 +58,8 @@ describe('Chart.controllers.doughnut', function() {
5858
});
5959

6060
var meta = chart.getDatasetMeta(0);
61-
expect(meta.data[0].circular).toBe(false);
62-
expect(meta.data[1].circular).toBe(false);
61+
expect(meta.data[0].options.spacingMode).toBe('angular');
62+
expect(meta.data[1].options.spacingMode).toBe('angular');
6363
});
6464

6565
it ('should reset and update elements', function() {

test/specs/controller.polarArea.tests.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('Chart.controllers.polarArea', function() {
6666
expect(meta.data[3] instanceof Chart.elements.ArcElement).toBe(true);
6767
});
6868

69-
it('should mark arcs as circular', function() {
69+
it('should default arc spacingMode to proportional', function() {
7070
var chart = window.acquireChart({
7171
type: 'polarArea',
7272
data: {
@@ -78,8 +78,30 @@ describe('Chart.controllers.polarArea', function() {
7878
});
7979

8080
var meta = chart.getDatasetMeta(0);
81-
expect(meta.data[0].circular).toBe(true);
82-
expect(meta.data[1].circular).toBe(true);
81+
expect(meta.data[0].options.spacingMode).toBe('proportional');
82+
expect(meta.data[1].options.spacingMode).toBe('proportional');
83+
});
84+
85+
it('should respect chart-level spacingMode override', function() {
86+
var chart = window.acquireChart({
87+
type: 'polarArea',
88+
data: {
89+
datasets: [{
90+
data: [10, 20]
91+
}],
92+
labels: ['a', 'b']
93+
},
94+
options: {
95+
spacing: 12,
96+
spacingMode: 'parallel'
97+
}
98+
});
99+
100+
var meta = chart.getDatasetMeta(0);
101+
expect(meta.data[0].options.spacingMode).toBe('parallel');
102+
expect(meta.data[1].options.spacingMode).toBe('parallel');
103+
expect(meta.data[0].options.spacing).toBe(12);
104+
expect(meta.data[1].options.spacing).toBe(12);
83105
});
84106

85107
it('should draw all elements', function() {

0 commit comments

Comments
 (0)