From: Daniel Cohen Gindi Date: Thu, 16 Apr 2026 11:49:43 +0000 (+0300) Subject: Corrected spacing in pie/doughnut/polarArea (Fixes #10059) (#12238) X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;ds=inline;p=thirdparty%2FChart.js.git 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 --- diff --git a/docs/charts/doughnut.md b/docs/charts/doughnut.md index 0209a8f9b..36d7680d5 100644 --- a/docs/charts/doughnut.md +++ b/docs/charts/doughnut.md @@ -100,30 +100,31 @@ Namespaces: 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) diff --git a/docs/charts/polar.md b/docs/charts/polar.md index 068cebcce..0d97e0715 100644 --- a/docs/charts/polar.md +++ b/docs/charts/polar.md @@ -71,6 +71,8 @@ The following options can be included in a polar area chart dataset to configure | [`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) diff --git a/src/controllers/controller.doughnut.js b/src/controllers/controller.doughnut.js index 904dd8eb1..a159b42c8 100644 --- a/src/controllers/controller.doughnut.js +++ b/src/controllers/controller.doughnut.js @@ -71,12 +71,15 @@ export default class DoughnutController extends DatasetController { // 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'), }; /** diff --git a/src/controllers/controller.polarArea.js b/src/controllers/controller.polarArea.js index 9514cf7c7..706d0d92e 100644 --- a/src/controllers/controller.polarArea.js +++ b/src/controllers/controller.polarArea.js @@ -22,6 +22,8 @@ export default class PolarAreaController extends DatasetController { }, indexAxis: 'r', startAngle: 0, + spacing: 0, + spacingMode: 'proportional', }; /** @@ -199,6 +201,14 @@ export default class PolarAreaController extends DatasetController { 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); } } diff --git a/src/elements/element.arc.ts b/src/elements/element.arc.ts index 42f41f045..4b629a9e7 100644 --- a/src/elements/element.arc.ts +++ b/src/elements/element.arc.ts @@ -98,6 +98,28 @@ function rThetaToXY(r: number, theta: number, x: number, y: number) { }; } +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. @@ -122,12 +144,23 @@ function pathArc( 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 @@ -135,26 +168,64 @@ function pathArc( // 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(); @@ -167,38 +238,38 @@ function pathArc( // 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); @@ -265,6 +336,7 @@ function drawBorder( } 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) { @@ -279,7 +351,8 @@ function drawBorder( 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); } @@ -311,6 +384,7 @@ export default class ArcElement extends Element { borderWidth: 2, offset: 0, spacing: 0, + spacingMode: 'angular', angle: undefined, circular: true, selfJoin: false, @@ -332,6 +406,7 @@ export default class ArcElement extends Element { outerRadius: number; pixelMargin: number; startAngle: number; + circular: boolean; constructor(cfg) { super(); @@ -344,6 +419,7 @@ export default class ArcElement extends Element { this.outerRadius = undefined; this.pixelMargin = 0; this.fullCircles = 0; + this.circular = false; if (cfg) { Object.assign(this, cfg); diff --git a/src/types.ts b/src/types.ts index ff16b8be5..84274f26e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,7 @@ export { ElementOptionsByType, ChartDatasetProperties, UpdateModeEnum, + ArcSpacingMode, registerables } from './types/index.js'; export * from './types/index.js'; diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 911b4cb2b..32831adc8 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -274,11 +274,20 @@ export interface DoughnutControllerDatasetOptions 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'> { @@ -327,11 +336,20 @@ export interface DoughnutControllerChartOptions { 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; } @@ -386,6 +404,18 @@ export interface PolarAreaControllerChartOptions { */ startAngle: number; + /** + * Spacing between the arcs + * @default 0 + */ + spacing: number; + + /** + * Geometry used to apply arc spacing. + * @default 'proportional' + */ + spacingMode: ArcSpacingMode; + animation: false | PolarAreaAnimationOptions; } @@ -1847,6 +1877,8 @@ export interface ArcBorderRadius { 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 @@ -1893,7 +1925,20 @@ export interface ArcOptions extends CommonElementOptions { /** * 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 { diff --git a/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.js b/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.js index 9bb4415ad..d485f4dcc 100644 --- a/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.js +++ b/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.js @@ -9,7 +9,7 @@ module.exports = { backgroundColor: 'transparent', borderColor: '#000', borderWidth: 10, - spacing: 50, + spacing: 20, }, ] }, diff --git a/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.png b/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.png index ecb2a2de3..a3f61ed74 100644 Binary files a/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.png and b/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.png differ diff --git a/test/fixtures/controller.doughnut/borderJoinStyle/miter.js b/test/fixtures/controller.doughnut/borderJoinStyle/miter.js index f50e923c0..b8223542f 100644 --- a/test/fixtures/controller.doughnut/borderJoinStyle/miter.js +++ b/test/fixtures/controller.doughnut/borderJoinStyle/miter.js @@ -10,7 +10,7 @@ module.exports = { borderColor: '#000', borderJoinStyle: 'miter', borderWidth: 10, - spacing: 50, + spacing: 20, }, ] }, diff --git a/test/fixtures/controller.doughnut/borderJoinStyle/miter.png b/test/fixtures/controller.doughnut/borderJoinStyle/miter.png index 6ec65b1f0..a8bf39fc5 100644 Binary files a/test/fixtures/controller.doughnut/borderJoinStyle/miter.png and b/test/fixtures/controller.doughnut/borderJoinStyle/miter.png differ diff --git a/test/fixtures/controller.doughnut/borderJoinStyle/round.js b/test/fixtures/controller.doughnut/borderJoinStyle/round.js index 43aa7ca6c..baa2cb948 100644 --- a/test/fixtures/controller.doughnut/borderJoinStyle/round.js +++ b/test/fixtures/controller.doughnut/borderJoinStyle/round.js @@ -10,7 +10,7 @@ module.exports = { borderColor: '#000', borderJoinStyle: 'round', borderWidth: 10, - spacing: 50, + spacing: 20, }, ] }, diff --git a/test/fixtures/controller.doughnut/borderJoinStyle/round.png b/test/fixtures/controller.doughnut/borderJoinStyle/round.png index dab62871e..01a715f34 100644 Binary files a/test/fixtures/controller.doughnut/borderJoinStyle/round.png and b/test/fixtures/controller.doughnut/borderJoinStyle/round.png differ diff --git a/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.js b/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.js index d2e0c59b0..0efb3a3a7 100644 --- a/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.js +++ b/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.js @@ -22,7 +22,7 @@ module.exports = { ], }, options: { - spacing: 50, + spacing: 20, 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 index 0a68820fa..638bd2de8 100644 Binary files a/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.png and b/test/fixtures/controller.doughnut/doughnut-spacing-and-offset.png differ diff --git a/test/fixtures/controller.doughnut/doughnut-spacing-parallel.js b/test/fixtures/controller.doughnut/doughnut-spacing-parallel.js new file mode 100644 index 000000000..b4985a424 --- /dev/null +++ b/test/fixtures/controller.doughnut/doughnut-spacing-parallel.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: 20, + spacingMode: 'parallel', + } + } +}; diff --git a/test/fixtures/controller.doughnut/doughnut-spacing-parallel.png b/test/fixtures/controller.doughnut/doughnut-spacing-parallel.png new file mode 100644 index 000000000..af123fcd5 Binary files /dev/null and b/test/fixtures/controller.doughnut/doughnut-spacing-parallel.png differ diff --git a/test/fixtures/controller.doughnut/doughnut-spacing.js b/test/fixtures/controller.doughnut/doughnut-spacing.js index c6c843792..b5c9c5c4e 100644 --- a/test/fixtures/controller.doughnut/doughnut-spacing.js +++ b/test/fixtures/controller.doughnut/doughnut-spacing.js @@ -22,7 +22,7 @@ module.exports = { ], }, options: { - spacing: 50, + spacing: 20, } } }; diff --git a/test/fixtures/controller.doughnut/doughnut-spacing.png b/test/fixtures/controller.doughnut/doughnut-spacing.png index d586621e9..14f6d546c 100644 Binary files a/test/fixtures/controller.doughnut/doughnut-spacing.png and b/test/fixtures/controller.doughnut/doughnut-spacing.png differ diff --git a/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-no-selfjoin.js b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-no-selfjoin.js new file mode 100644 index 000000000..16332d93a --- /dev/null +++ b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-no-selfjoin.js @@ -0,0 +1,19 @@ +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, + } + } +}; diff --git a/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-no-selfjoin.png b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-no-selfjoin.png new file mode 100644 index 000000000..8575e1395 Binary files /dev/null and b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-no-selfjoin.png differ diff --git a/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.js b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.js new file mode 100644 index 000000000..4e7a1e823 --- /dev/null +++ b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.js @@ -0,0 +1,19 @@ +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, + } + } +}; diff --git a/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.png b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.png new file mode 100644 index 000000000..b9ab0aace Binary files /dev/null and b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.png differ diff --git a/test/specs/controller.doughnut.tests.js b/test/specs/controller.doughnut.tests.js index a4596b690..ff45ab831 100644 --- a/test/specs/controller.doughnut.tests.js +++ b/test/specs/controller.doughnut.tests.js @@ -46,6 +46,22 @@ describe('Chart.controllers.doughnut', function() { 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', diff --git a/test/specs/controller.polarArea.tests.js b/test/specs/controller.polarArea.tests.js index 394cdb573..0ba65e35e 100644 --- a/test/specs/controller.polarArea.tests.js +++ b/test/specs/controller.polarArea.tests.js @@ -66,6 +66,44 @@ describe('Chart.controllers.polarArea', function() { 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', diff --git a/test/specs/element.arc.tests.js b/test/specs/element.arc.tests.js index 63d20caae..b832ac8ba 100644 --- a/test/specs/element.arc.tests.js +++ b/test/specs/element.arc.tests.js @@ -303,4 +303,283 @@ describe('Arc element tests', function() { 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(); + }); }); diff --git a/test/types/options.ts b/test/types/options.ts index 21d0ccf17..b848475df 100644 --- a/test/types/options.ts +++ b/test/types/options.ts @@ -1,4 +1,4 @@ -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', @@ -40,6 +40,7 @@ const doughnutOptions: DoughnutControllerChartOptions = { rotation: 0, spacing: 0, animation: false, + spacingMode: 'angular', }; const chartOptions: ChartOptions = doughnutOptions;