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.
-| Name | Type | [Scriptable](../general/options.md#scriptable-options) | [Indexable](../general/options.md#indexable-options) | Default
-| ---- | ---- | :----: | :----: | ----
-| [`backgroundColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'rgba(0, 0, 0, 0.1)'`
-| [`borderAlign`](#border-alignment) | `'center'`\|`'inner'` | Yes | Yes | `'center'`
-| [`borderColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'#fff'`
-| [`borderDash`](#styling) | `number[]` | Yes | - | `[]`
-| [`borderDashOffset`](#styling) | `number` | Yes | - | `0.0`
-| [`borderJoinStyle`](#styling) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
-| [`borderRadius`](#border-radius) | `number`\|`object` | Yes | Yes | `0`
-| [`borderWidth`](#styling) | `number` | Yes | Yes | `2`
-| [`circumference`](#general) | `number` | - | - | `undefined`
-| [`clip`](#general) | `number`\|`object`\|`false` | - | - | `undefined`
-| [`data`](#data-structure) | `number[]` | - | - | **required**
-| [`hoverBackgroundColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
-| [`hoverBorderColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
-| [`hoverBorderDash`](#interactions) | `number[]` | Yes | - | `undefined`
-| [`hoverBorderDashOffset`](#interactions) | `number` | Yes | - | `undefined`
-| [`hoverBorderJoinStyle`](#interactions) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
-| [`hoverBorderWidth`](#interactions) | `number` | Yes | Yes | `undefined`
-| [`hoverOffset`](#interactions) | `number` | Yes | Yes | `0`
-| [`offset`](#styling) | `number`\|`number[]` | Yes | Yes | `0`
-| [`rotation`](#general) | `number` | - | - | `undefined`
-| [`spacing`](#styling) | `number` | - | - | `0`
-| [`weight`](#styling) | `number` | - | - | `1`
+| Name | Type | [Scriptable](../general/options.md#scriptable-options) | [Indexable](../general/options.md#indexable-options) | Default
+|------------------------------------------|---------------------------------| :----: | :----: | ----
+| [`backgroundColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'rgba(0, 0, 0, 0.1)'`
+| [`borderAlign`](#border-alignment) | `'center'`\|`'inner'` | Yes | Yes | `'center'`
+| [`borderColor`](#styling) | [`Color`](../general/colors.md) | Yes | Yes | `'#fff'`
+| [`borderDash`](#styling) | `number[]` | Yes | - | `[]`
+| [`borderDashOffset`](#styling) | `number` | Yes | - | `0.0`
+| [`borderJoinStyle`](#styling) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
+| [`borderRadius`](#border-radius) | `number`\|`object` | Yes | Yes | `0`
+| [`borderWidth`](#styling) | `number` | Yes | Yes | `2`
+| [`circumference`](#general) | `number` | - | - | `undefined`
+| [`clip`](#general) | `number`\|`object`\|`false` | - | - | `undefined`
+| [`data`](#data-structure) | `number[]` | - | - | **required**
+| [`hoverBackgroundColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
+| [`hoverBorderColor`](#interactions) | [`Color`](../general/colors.md) | Yes | Yes | `undefined`
+| [`hoverBorderDash`](#interactions) | `number[]` | Yes | - | `undefined`
+| [`hoverBorderDashOffset`](#interactions) | `number` | Yes | - | `undefined`
+| [`hoverBorderJoinStyle`](#interactions) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
+| [`hoverBorderWidth`](#interactions) | `number` | Yes | Yes | `undefined`
+| [`hoverOffset`](#interactions) | `number` | Yes | Yes | `0`
+| [`offset`](#styling) | `number`\|`number[]` | Yes | Yes | `0`
+| [`rotation`](#general) | `number` | - | - | `undefined`
+| [`spacing`](#styling) | `number` | - | - | `0`
+| [`spacingMode`](#styling) | `angular`\|`proportional`\|`parallel` | - | - | `angular`
+| [`weight`](#styling) | `number` | - | - | `1`
All these values, if `undefined`, fallback to the scopes described in [option resolution](../general/options)
| [`hoverBorderJoinStyle`](#interactions) | `'round'`\|`'bevel'`\|`'miter'` | Yes | Yes | `undefined`
| [`hoverBorderWidth`](#interactions) | `number` | Yes | Yes | `undefined`
| [`circular`](#styling) | `boolean` | Yes | Yes | `true`
+| [`spacing`](#styling) | `number` | - | - | `0`
+| [`spacingMode`](#styling) | `angular`\|`proportional`\|`parallel` | - | - | `proportional`
All these values, if `undefined`, fallback to the scopes described in [option resolution](../general/options)
// Spacing between arcs
spacing: 0,
+ // Geometry used to apply spacing between arcs
+ spacingMode: 'angular',
+
indexAxis: 'r',
};
static descriptors = {
- _scriptable: (name) => name !== 'spacing',
- _indexable: (name) => name !== 'spacing' && !name.startsWith('borderDash') && !name.startsWith('hoverBorderDash'),
+ _scriptable: (name) => name !== 'spacing' && name !== 'spacingMode',
+ _indexable: (name) => name !== 'spacing' && name !== 'spacingMode' && !name.startsWith('borderDash') && !name.startsWith('hoverBorderDash'),
};
/**
},
indexAxis: 'r',
startAngle: 0,
+ spacing: 0,
+ spacingMode: 'proportional',
};
/**
options: this.resolveDataElementOptions(i, arc.active ? 'active' : mode)
};
+ // Arc defaults (`spacing=0`, `spacingMode='angular'`) can mask polarArea-level values.
+ if (properties.options.spacing === 0) {
+ properties.options.spacing = this.options.spacing;
+ }
+ if (properties.options.spacingMode === 'angular') {
+ properties.options.spacingMode = this.options.spacingMode;
+ }
+
this.updateElement(arc, i, properties, mode);
}
}
};
}
+function pathFullCircle(
+ ctx: CanvasRenderingContext2D,
+ element: ArcElement,
+ offset: number,
+ spacing: number,
+) {
+ const {x, y, startAngle: start, pixelMargin, innerRadius: innerR} = element;
+ const outerRadius = Math.max(element.outerRadius + spacing + offset - pixelMargin, 0);
+ const innerRadius = innerR > 0 ? innerR + spacing + offset + pixelMargin : 0;
+
+ ctx.beginPath();
+ ctx.arc(x, y, outerRadius, start, start + TAU);
+
+ if (innerRadius > 0) {
+ // Start the inner contour as a separate subpath to avoid a seam connector.
+ ctx.moveTo(x + Math.cos(start) * innerRadius, y + Math.sin(start) * innerRadius);
+ ctx.arc(x, y, innerRadius, start + TAU, start, true);
+ }
+
+ ctx.closePath();
+}
+
/**
* Path the arc, respecting border radius by separating into left and right halves.
circular: boolean,
) {
const {x, y, startAngle: start, pixelMargin, innerRadius: innerR} = element;
+ const {spacingMode = 'angular'} = element.options;
+ const alpha = end - start;
+
+ if (circular && element.options.selfJoin && Math.abs(alpha) >= TAU - 1e-4) {
+ pathFullCircle(ctx, element, offset, spacing);
+ return;
+ }
const outerRadius = Math.max(element.outerRadius + spacing + offset - pixelMargin, 0);
- const innerRadius = innerR > 0 ? innerR + spacing + offset + pixelMargin : 0;
+ let innerRadius = innerR > 0 ? innerR + spacing + offset + pixelMargin : 0;
- let spacingOffset = 0;
- const alpha = end - start;
+ let outerSpacingOffset = 0;
+ let innerSpacingOffset = 0;
+ const beta = outerRadius > 0
+ ? Math.max(0.001, alpha * outerRadius - offset / PI) / outerRadius
+ : 0.001;
+ const angleOffset = (alpha - beta) / 2;
if (spacing) {
// When spacing is present, it is the same for all items
// 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 avgNoSpacingRadius = (noSpacingInnerRadius + noSpacingOuterRadius) / 2;
+ const proportionalOffset = (() => {
+ const adjustedAngle = avgNoSpacingRadius !== 0 ? (alpha * avgNoSpacingRadius) / (avgNoSpacingRadius + spacing) : alpha;
+ return (alpha - adjustedAngle) / 2;
+ })();
+ const angularOffset = avgNoSpacingRadius > 0 ? Math.asin(Math.min(1, spacing / avgNoSpacingRadius)) : 0;
+
+ // Keep spacing trims below half the available span after base offset trimming.
+ const maxOffset = Math.max(0, beta / 2 - 0.001);
+ const maxOffsetSin = Math.sin(maxOffset);
+
+ if (spacingMode === 'parallel') {
+ if (innerRadius === 0 && maxOffsetSin > 0) {
+ // A root radius of zero cannot realize a non-zero parallel separator width.
+ // Raise the root just enough for the available angular span.
+ const minInnerRadius = spacing / maxOffsetSin;
+ const maxInnerRadius = Math.max(0, outerRadius - 0.001);
+ innerRadius = Math.min(minInnerRadius, maxInnerRadius);
+ }
+
+ // Use one bounded spacing value for both radii so large spacing keeps stable geometry.
+ const maxParallelSpacing = Math.min(
+ outerRadius > 0 ? outerRadius * maxOffsetSin : Number.POSITIVE_INFINITY,
+ innerRadius > 0 ? innerRadius * maxOffsetSin : Number.POSITIVE_INFINITY
+ );
+ const parallelSpacing = Math.min(spacing, maxParallelSpacing);
+
+ outerSpacingOffset = outerRadius > 0
+ ? Math.asin(Math.min(1, parallelSpacing / outerRadius))
+ : Math.min(maxOffset, angularOffset);
+ innerSpacingOffset = innerRadius > 0
+ ? Math.asin(Math.min(1, parallelSpacing / innerRadius))
+ : outerSpacingOffset;
+ } else if (spacingMode === 'proportional') {
+ outerSpacingOffset = Math.min(maxOffset, proportionalOffset);
+ innerSpacingOffset = Math.min(maxOffset, proportionalOffset);
+ } else {
+ outerSpacingOffset = Math.min(maxOffset, angularOffset);
+ innerSpacingOffset = Math.min(maxOffset, angularOffset);
+ }
}
- const beta = Math.max(0.001, alpha * outerRadius - offset / PI) / outerRadius;
- const angleOffset = (alpha - beta) / 2;
- const startAngle = start + angleOffset + spacingOffset;
- const endAngle = end - angleOffset - spacingOffset;
- const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius(element, innerRadius, outerRadius, endAngle - startAngle);
+ const outerStartAngle = start + angleOffset + outerSpacingOffset;
+ const outerEndAngle = end - angleOffset - outerSpacingOffset;
+ const innerStartAngle = start + angleOffset + innerSpacingOffset;
+ const innerEndAngle = end - angleOffset - innerSpacingOffset;
+ const angleDelta = Math.min(outerEndAngle - outerStartAngle, innerEndAngle - innerStartAngle);
+ const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius(element, innerRadius, outerRadius, angleDelta);
const outerStartAdjustedRadius = outerRadius - outerStart;
const outerEndAdjustedRadius = outerRadius - outerEnd;
- const outerStartAdjustedAngle = startAngle + outerStart / outerStartAdjustedRadius;
- const outerEndAdjustedAngle = endAngle - outerEnd / outerEndAdjustedRadius;
+ const outerStartAdjustedAngle = outerStartAngle + outerStart / outerStartAdjustedRadius;
+ const outerEndAdjustedAngle = outerEndAngle - outerEnd / outerEndAdjustedRadius;
const innerStartAdjustedRadius = innerRadius + innerStart;
const innerEndAdjustedRadius = innerRadius + innerEnd;
- const innerStartAdjustedAngle = startAngle + innerStart / innerStartAdjustedRadius;
- const innerEndAdjustedAngle = endAngle - innerEnd / innerEndAdjustedRadius;
+ const innerStartAdjustedAngle = innerStartAngle + innerStart / innerStartAdjustedRadius;
+ const innerEndAdjustedAngle = innerEndAngle - innerEnd / innerEndAdjustedRadius;
ctx.beginPath();
// The corner segment from point 2 to point 3
if (outerEnd > 0) {
const pCenter = rThetaToXY(outerEndAdjustedRadius, outerEndAdjustedAngle, x, y);
- ctx.arc(pCenter.x, pCenter.y, outerEnd, outerEndAdjustedAngle, endAngle + HALF_PI);
+ ctx.arc(pCenter.x, pCenter.y, outerEnd, outerEndAdjustedAngle, outerEndAngle + HALF_PI);
}
// The line from point 3 to point 4
- const p4 = rThetaToXY(innerEndAdjustedRadius, endAngle, x, y);
+ const p4 = rThetaToXY(innerEndAdjustedRadius, innerEndAngle, x, y);
ctx.lineTo(p4.x, p4.y);
// The corner segment from point 4 to point 5
if (innerEnd > 0) {
const pCenter = rThetaToXY(innerEndAdjustedRadius, innerEndAdjustedAngle, x, y);
- ctx.arc(pCenter.x, pCenter.y, innerEnd, endAngle + HALF_PI, innerEndAdjustedAngle + Math.PI);
+ ctx.arc(pCenter.x, pCenter.y, innerEnd, innerEndAngle + HALF_PI, innerEndAdjustedAngle + Math.PI);
}
// The inner arc from point 5 to point b to point 6
- const innerMidAdjustedAngle = ((endAngle - (innerEnd / innerRadius)) + (startAngle + (innerStart / innerRadius))) / 2;
- ctx.arc(x, y, innerRadius, endAngle - (innerEnd / innerRadius), innerMidAdjustedAngle, true);
- ctx.arc(x, y, innerRadius, innerMidAdjustedAngle, startAngle + (innerStart / innerRadius), true);
+ const innerMidAdjustedAngle = ((innerEndAngle - (innerEnd / innerRadius)) + (innerStartAngle + (innerStart / innerRadius))) / 2;
+ ctx.arc(x, y, innerRadius, innerEndAngle - (innerEnd / innerRadius), innerMidAdjustedAngle, true);
+ ctx.arc(x, y, innerRadius, innerMidAdjustedAngle, innerStartAngle + (innerStart / innerRadius), true);
// The corner segment from point 6 to point 7
if (innerStart > 0) {
const pCenter = rThetaToXY(innerStartAdjustedRadius, innerStartAdjustedAngle, x, y);
- ctx.arc(pCenter.x, pCenter.y, innerStart, innerStartAdjustedAngle + Math.PI, startAngle - HALF_PI);
+ ctx.arc(pCenter.x, pCenter.y, innerStart, innerStartAdjustedAngle + Math.PI, innerStartAngle - HALF_PI);
}
// The line from point 7 to point 8
- const p8 = rThetaToXY(outerStartAdjustedRadius, startAngle, x, y);
+ const p8 = rThetaToXY(outerStartAdjustedRadius, outerStartAngle, x, y);
ctx.lineTo(p8.x, p8.y);
// The corner segment from point 8 to point 1
if (outerStart > 0) {
const pCenter = rThetaToXY(outerStartAdjustedRadius, outerStartAdjustedAngle, x, y);
- ctx.arc(pCenter.x, pCenter.y, outerStart, startAngle - HALF_PI, outerStartAdjustedAngle);
+ ctx.arc(pCenter.x, pCenter.y, outerStart, outerStartAngle - HALF_PI, outerStartAdjustedAngle);
}
} else {
ctx.moveTo(x, y);
}
let endAngle = element.endAngle;
+ const isFullCircle = Math.abs(endAngle - startAngle) >= TAU - 1e-4;
if (fullCircles) {
pathArc(ctx, element, offset, spacing, endAngle, circular);
for (let i = 0; i < fullCircles; ++i) {
clipArc(ctx, element, endAngle);
}
- if (options.selfJoin && endAngle - startAngle >= PI && borderRadius === 0 && borderJoinStyle !== 'miter') {
+ const skipSelfClip = isFullCircle && element.innerRadius > 0;
+ if (!skipSelfClip && options.selfJoin && endAngle - startAngle >= PI && borderRadius === 0 && borderJoinStyle !== 'miter') {
clipSelf(ctx, element, endAngle);
}
borderWidth: 2,
offset: 0,
spacing: 0,
+ spacingMode: 'angular',
angle: undefined,
circular: true,
selfJoin: false,
outerRadius: number;
pixelMargin: number;
startAngle: number;
+ circular: boolean;
constructor(cfg) {
super();
this.outerRadius = undefined;
this.pixelMargin = 0;
this.fullCircles = 0;
+ this.circular = false;
if (cfg) {
Object.assign(this, cfg);
ElementOptionsByType,
ChartDatasetProperties,
UpdateModeEnum,
+ ArcSpacingMode,
registerables
} from './types/index.js';
export * from './types/index.js';
weight: number;
/**
- * Similar to the `offset` option, but applies to all arcs. This can be used to to add spaces
- * between arcs
- * @default 0
- */
+ * Similar to the `offset` option, but applies to all arcs. This can be used to to add spaces
+ * between arcs
+ * @default 0
+ */
spacing: number;
+
+ /**
+ * Geometry used to apply arc spacing.
+ * - `proportional`: legacy behavior (default for polarArea).
+ * - `angular`: constant angular trim (default for doughnut/pie).
+ * - `parallel`: separate inner/outer trims for constant-width separators (best for small-moderate spacings).
+ * @default 'angular'
+ */
+ spacingMode: ArcSpacingMode;
}
export interface DoughnutAnimationOptions extends AnimationSpec<'doughnut'> {
rotation: number;
/**
- * Spacing between the arcs
- * @default 0
- */
+ * Spacing between the arcs
+ * @default 0
+ */
spacing: number;
+ /**
+ * Geometry used to apply arc spacing.
+ * - `proportional`: legacy behavior (default for polarArea).
+ * - `angular`: constant angular trim (default for doughnut/pie).
+ * - `parallel`: separate inner/outer trims for constant-width separators (best for small-moderate spacings).
+ * @default 'angular'
+ */
+ spacingMode: ArcSpacingMode;
+
animation: false | DoughnutAnimationOptions;
}
*/
startAngle: number;
+ /**
+ * Spacing between the arcs
+ * @default 0
+ */
+ spacing: number;
+
+ /**
+ * Geometry used to apply arc spacing.
+ * @default 'proportional'
+ */
+ spacingMode: ArcSpacingMode;
+
animation: false | PolarAreaAnimationOptions;
}
innerEnd: number;
}
+export type ArcSpacingMode = 'proportional' | 'angular' | 'parallel';
+
export interface ArcOptions extends CommonElementOptions {
/**
* 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
/**
* Spacing between arcs
*/
- spacing: number
+ spacing: number;
+
+ /**
+ * Geometry used to apply arc spacing. Only applies to chart types with arc elements (doughnut, pie, polarArea).
+ * Radar charts use line elements and do not support arc spacing modes.
+ *
+ * - `proportional`: keeps the legacy proportional spacing behavior.
+ * - `angular`: applies a constant angular trim based on average radius.
+ * - `parallel`: applies separate inner/outer trims to keep separator width constant.
+ * Works best for small to moderate spacings. For very large spacings, the offset is capped
+ * to prevent arc angle reversals.
+ * @default 'angular'
+ */
+ spacingMode: ArcSpacingMode;
}
export interface ArcHoverOptions extends CommonHoverOptions {
backgroundColor: 'transparent',
borderColor: '#000',
borderWidth: 10,
- spacing: 50,
+ spacing: 20,
},
]
},
borderColor: '#000',
borderJoinStyle: 'miter',
borderWidth: 10,
- spacing: 50,
+ spacing: 20,
},
]
},
borderColor: '#000',
borderJoinStyle: 'round',
borderWidth: 10,
- spacing: 50,
+ spacing: 20,
},
]
},
],
},
options: {
- spacing: 50,
+ spacing: 20,
offset: [0, 50, 0, 0, 0],
}
}
--- /dev/null
+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: 20,
+ spacingMode: 'parallel',
+ }
+ }
+};
],
},
options: {
- spacing: 50,
+ spacing: 20,
}
}
};
--- /dev/null
+module.exports = {
+ tolerance: 0.015,
+ config: {
+ type: 'doughnut',
+ data: {
+ labels: ['A'],
+ datasets: [{
+ data: [360],
+ backgroundColor: '#dda7ee',
+ borderColor: '#6e0d8f',
+ borderWidth: 1,
+ selfJoin: false,
+ }]
+ },
+ options: {
+ spacing: 10,
+ }
+ }
+};
--- /dev/null
+module.exports = {
+ tolerance: 0.015,
+ config: {
+ type: 'doughnut',
+ data: {
+ labels: ['A'],
+ datasets: [{
+ data: [360],
+ backgroundColor: '#dda7ee',
+ borderColor: '#6e0d8f',
+ borderWidth: 1,
+ selfJoin: true,
+ }]
+ },
+ options: {
+ spacing: 10,
+ }
+ }
+};
expect(meta.data[3] instanceof Chart.elements.ArcElement).toBe(true);
});
+ it('should default arc spacingMode to angular', function() {
+ var chart = window.acquireChart({
+ type: 'doughnut',
+ data: {
+ datasets: [{
+ data: [10, 20]
+ }],
+ labels: ['a', 'b']
+ }
+ });
+
+ var meta = chart.getDatasetMeta(0);
+ expect(meta.data[0].options.spacingMode).toBe('angular');
+ expect(meta.data[1].options.spacingMode).toBe('angular');
+ });
+
it ('should reset and update elements', function() {
var chart = window.acquireChart({
type: 'doughnut',
expect(meta.data[3] instanceof Chart.elements.ArcElement).toBe(true);
});
+ it('should default arc spacingMode to proportional', function() {
+ var chart = window.acquireChart({
+ type: 'polarArea',
+ data: {
+ datasets: [{
+ data: [10, 20]
+ }],
+ labels: ['a', 'b']
+ }
+ });
+
+ var meta = chart.getDatasetMeta(0);
+ expect(meta.data[0].options.spacingMode).toBe('proportional');
+ expect(meta.data[1].options.spacingMode).toBe('proportional');
+ });
+
+ it('should respect chart-level spacingMode override', function() {
+ var chart = window.acquireChart({
+ type: 'polarArea',
+ data: {
+ datasets: [{
+ data: [10, 20]
+ }],
+ labels: ['a', 'b']
+ },
+ options: {
+ spacing: 12,
+ spacingMode: 'parallel'
+ }
+ });
+
+ var meta = chart.getDatasetMeta(0);
+ expect(meta.data[0].options.spacingMode).toBe('parallel');
+ expect(meta.data[1].options.spacingMode).toBe('parallel');
+ expect(meta.data[0].options.spacing).toBe(12);
+ expect(meta.data[1].options.spacing).toBe(12);
+ });
+
it('should draw all elements', function() {
var chart = window.acquireChart({
type: 'polarArea',
expect(arc.inRange(center.x, 1)).toBe(false);
});
+
+ it('should use angular spacing mode independently from proportional mode', function() {
+ function createArc(spacingMode) {
+ return new Chart.elements.ArcElement({
+ startAngle: 0,
+ endAngle: Math.PI / 2,
+ x: 0,
+ y: 0,
+ innerRadius: 0,
+ outerRadius: 100,
+ options: {
+ circular: true,
+ spacingMode: spacingMode,
+ spacing: 20,
+ offset: 0,
+ borderWidth: 0,
+ borderRadius: 0,
+ backgroundColor: 'red',
+ borderColor: 'black'
+ }
+ });
+ }
+
+ function firstOuterArcStartAngle(arc) {
+ var ctx = window.createMockContext();
+ arc.draw(ctx);
+
+ var arcCall = ctx.getCalls().filter(function(x) {
+ return x.name === 'arc';
+ })[0];
+ if (arcCall) {
+ return arcCall.args[3];
+ }
+
+ var lineToCall = ctx.getCalls().filter(function(x) {
+ return x.name === 'lineTo';
+ })[0];
+ var dx = lineToCall.args[0] - arc.x;
+ var dy = lineToCall.args[1] - arc.y;
+ return Math.atan2(dy, dx);
+ }
+
+ var proportionalStart = firstOuterArcStartAngle(createArc('proportional'));
+ var angularStart = firstOuterArcStartAngle(createArc('angular'));
+ var alpha = Math.PI / 2;
+ var spacing = 10; // draw() passes spacing / 2 to pathArc
+ var avgNoSpacingRadius = 50;
+ var adjustedAngle = (alpha * avgNoSpacingRadius) / (avgNoSpacingRadius + spacing);
+ var proportionalSpacingOffset = (alpha - adjustedAngle) / 2;
+ var angularSpacingOffset = Math.asin(Math.min(1, spacing / avgNoSpacingRadius));
+
+ expect(angularStart - proportionalStart).toBeCloseTo(angularSpacingOffset - proportionalSpacingOffset, 6);
+ });
+
+ it('should keep valid arc direction with large parallel spacing', function() {
+ var arc = new Chart.elements.ArcElement({
+ startAngle: 0,
+ endAngle: Math.PI / 2,
+ x: 0,
+ y: 0,
+ innerRadius: 40,
+ outerRadius: 100,
+ options: {
+ circular: true,
+ spacingMode: 'parallel',
+ spacing: 40,
+ offset: 0,
+ borderWidth: 0,
+ borderRadius: 0,
+ backgroundColor: 'red',
+ borderColor: 'black'
+ }
+ });
+
+ var ctx = window.createMockContext();
+ arc.draw(ctx);
+
+ var arcCalls = ctx.getCalls().filter(function(x) {
+ return x.name === 'arc';
+ });
+
+ // First two calls are the split outer arc segments; ensure they keep forward direction.
+ expect(arcCalls[0].args[3]).toBeLessThan(arcCalls[0].args[4]);
+ expect(arcCalls[1].args[3]).toBeLessThan(arcCalls[1].args[4]);
+ });
+
+ it('should not reverse end separator angle in parallel mode with large spacing', function() {
+ var arc = new Chart.elements.ArcElement({
+ startAngle: 0,
+ endAngle: Math.PI / 3,
+ x: 0,
+ y: 0,
+ innerRadius: 40,
+ outerRadius: 100,
+ options: {
+ circular: true,
+ spacingMode: 'parallel',
+ spacing: 20,
+ offset: 0,
+ borderWidth: 0,
+ borderRadius: 0,
+ backgroundColor: 'red',
+ borderColor: 'black'
+ }
+ });
+
+ var ctx = window.createMockContext();
+ arc.draw(ctx);
+
+ var arcCalls = ctx.getCalls().filter(function(x) {
+ return x.name === 'arc';
+ });
+ var lineToCalls = ctx.getCalls().filter(function(x) {
+ return x.name === 'lineTo';
+ });
+
+ var outerEndAngle = arcCalls[1].args[4];
+ var innerEndAngle = Math.atan2(lineToCalls[0].args[1] - arc.y, lineToCalls[0].args[0] - arc.x);
+
+ expect(innerEndAngle).toBeLessThan(outerEndAngle);
+ });
+
+ it('should create a non-zero root for parallel spacing when innerRadius is zero', function() {
+ var arc = new Chart.elements.ArcElement({
+ startAngle: 0,
+ endAngle: Math.PI / 3,
+ x: 0,
+ y: 0,
+ innerRadius: 0,
+ outerRadius: 100,
+ options: {
+ circular: true,
+ spacingMode: 'parallel',
+ spacing: 20,
+ offset: 0,
+ borderWidth: 0,
+ borderRadius: 0,
+ backgroundColor: 'red',
+ borderColor: 'black'
+ }
+ });
+
+ var ctx = window.createMockContext();
+ arc.draw(ctx);
+
+ var lineToCalls = ctx.getCalls().filter(function(x) {
+ return x.name === 'lineTo';
+ });
+ var rootDistance = Math.hypot(lineToCalls[0].args[0] - arc.x, lineToCalls[0].args[1] - arc.y);
+
+ expect(rootDistance).toBeGreaterThan(0);
+ });
+
+ it('should render full-circle geometry for selfJoin with borderRadius', function() {
+ var arc = new Chart.elements.ArcElement({
+ startAngle: 0,
+ endAngle: Math.PI * 2,
+ x: 0,
+ y: 0,
+ innerRadius: 50,
+ outerRadius: 100,
+ options: {
+ circular: true,
+ selfJoin: true,
+ spacing: 0,
+ offset: 0,
+ borderWidth: 6,
+ borderRadius: 12,
+ backgroundColor: 'red',
+ borderColor: 'black'
+ }
+ });
+
+ var ctx = window.createMockContext();
+ arc.draw(ctx);
+
+ var arcCalls = ctx.getCalls().filter(function(x) {
+ return x.name === 'arc';
+ });
+ var radii = Array.from(new Set(arcCalls.map(function(x) {
+ return x.args[2].toFixed(3);
+ })));
+
+ expect(radii.length).toBe(2);
+ expect(Math.abs(arcCalls[0].args[4] - arcCalls[0].args[3])).toBeCloseTo(Math.PI * 2, 3);
+ });
+
+ it('should apply spacing to a full circle when selfJoin is false', function() {
+ var arc = new Chart.elements.ArcElement({
+ startAngle: 0,
+ endAngle: Math.PI * 2,
+ x: 0,
+ y: 0,
+ innerRadius: 40,
+ outerRadius: 100,
+ options: {
+ circular: true,
+ selfJoin: false,
+ spacingMode: 'angular',
+ spacing: 20,
+ offset: 0,
+ borderWidth: 0,
+ borderRadius: 0,
+ backgroundColor: 'red',
+ borderColor: 'black'
+ }
+ });
+
+ var ctx = window.createMockContext();
+ arc.draw(ctx);
+
+ var firstOuterArc = ctx.getCalls().filter(function(x) {
+ return x.name === 'arc';
+ })[0];
+
+ expect(firstOuterArc.args[4] - firstOuterArc.args[3]).toBeLessThan(Math.PI * 2);
+ });
+
+ it('should not clip full-circle selfJoin borders with evenodd clipping', function() {
+ var arc = new Chart.elements.ArcElement({
+ startAngle: 0,
+ endAngle: Math.PI * 2,
+ x: 0,
+ y: 0,
+ innerRadius: 40,
+ outerRadius: 100,
+ options: {
+ circular: true,
+ selfJoin: true,
+ spacing: 20,
+ offset: 0,
+ borderWidth: 2,
+ borderRadius: 0,
+ borderJoinStyle: 'round',
+ backgroundColor: 'red',
+ borderColor: 'black'
+ }
+ });
+
+ var ctx = window.createMockContext();
+ arc.draw(ctx);
+
+ var evenOddClipCall = ctx.getCalls().find(function(x) {
+ return x.name === 'clip' && x.args[0] === 'evenodd';
+ });
+
+ expect(evenOddClipCall).toBeUndefined();
+ });
+
+ it('should not draw a radial seam for full-circle selfJoin pie', function() {
+ var arc = new Chart.elements.ArcElement({
+ startAngle: 0,
+ endAngle: Math.PI * 2,
+ x: 10,
+ y: 20,
+ innerRadius: 0,
+ outerRadius: 100,
+ options: {
+ circular: true,
+ selfJoin: true,
+ spacing: 0,
+ offset: 0,
+ borderWidth: 10,
+ borderRadius: 1,
+ borderJoinStyle: 'round',
+ backgroundColor: 'red',
+ borderColor: 'black'
+ }
+ });
+
+ var ctx = window.createMockContext();
+ arc.draw(ctx);
+
+ var radialLineToCenter = ctx.getCalls().find(function(x) {
+ return x.name === 'lineTo' && x.args[0] === arc.x && x.args[1] === arc.y;
+ });
+
+ expect(radialLineToCenter).toBeUndefined();
+ });
});
-import { Chart, ChartOptions, ChartType, DoughnutControllerChartOptions } from '../../src/types.js';
+import {Chart, ChartOptions, ChartType, DoughnutControllerChartOptions} from '../../src/types.js';
const chart = new Chart('test', {
type: 'bar',
rotation: 0,
spacing: 0,
animation: false,
+ spacingMode: 'angular',
};
const chartOptions: ChartOptions<ChartType> = doughnutOptions;