]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Corrected spacing in pie/doughnut/polarArea (Fixes #10059) (#12238) master
authorDaniel Cohen Gindi <Danielgindi@gmail.com>
Thu, 16 Apr 2026 11:49:43 +0000 (14:49 +0300)
committerGitHub <noreply@github.com>
Thu, 16 Apr 2026 11:49:43 +0000 (13:49 +0200)
* 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

27 files changed:
docs/charts/doughnut.md
docs/charts/polar.md
src/controllers/controller.doughnut.js
src/controllers/controller.polarArea.js
src/elements/element.arc.ts
src/types.ts
src/types/index.d.ts
test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.js
test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.png
test/fixtures/controller.doughnut/borderJoinStyle/miter.js
test/fixtures/controller.doughnut/borderJoinStyle/miter.png
test/fixtures/controller.doughnut/borderJoinStyle/round.js
test/fixtures/controller.doughnut/borderJoinStyle/round.png
test/fixtures/controller.doughnut/doughnut-spacing-and-offset.js
test/fixtures/controller.doughnut/doughnut-spacing-and-offset.png
test/fixtures/controller.doughnut/doughnut-spacing-parallel.js [new file with mode: 0644]
test/fixtures/controller.doughnut/doughnut-spacing-parallel.png [new file with mode: 0644]
test/fixtures/controller.doughnut/doughnut-spacing.js
test/fixtures/controller.doughnut/doughnut-spacing.png
test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-no-selfjoin.js [new file with mode: 0644]
test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-no-selfjoin.png [new file with mode: 0644]
test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.js [new file with mode: 0644]
test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.png [new file with mode: 0644]
test/specs/controller.doughnut.tests.js
test/specs/controller.polarArea.tests.js
test/specs/element.arc.tests.js
test/types/options.ts

index 0209a8f9b999eb750c45626d905d1ea67e852eb9..36d7680d56169d35324214ef91c97fbb8a3cf0f5 100644 (file)
@@ -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)
 
index 068cebcce8b1db954ba28f81157ff4fa2d48cd6d..0d97e0715847fe338d563234ca3300dcb394026e 100644 (file)
@@ -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)
 
index 904dd8eb1c33188067a9754241a6f07a4450a10d..a159b42c86b782c2d1923ac6dfdbeea8805d4d86 100644 (file)
@@ -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'),
   };
 
   /**
index 9514cf7c7c782b2040ec291f0c39fdec8c4c661a..706d0d92eacf4cc7f7530e2869447e8b458ca6e2 100644 (file)
@@ -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);
     }
   }
index 42f41f045b0a7e43d32c967f7705ccf173d0e7e7..4b629a9e7ae33302728bcb2d7fd6db0122c51d60 100644 (file)
@@ -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<ArcProps, ArcOptions> {
     borderWidth: 2,
     offset: 0,
     spacing: 0,
+    spacingMode: 'angular',
     angle: undefined,
     circular: true,
     selfJoin: false,
@@ -332,6 +406,7 @@ export default class ArcElement extends Element<ArcProps, ArcOptions> {
   outerRadius: number;
   pixelMargin: number;
   startAngle: number;
+  circular: boolean;
 
   constructor(cfg) {
     super();
@@ -344,6 +419,7 @@ export default class ArcElement extends Element<ArcProps, ArcOptions> {
     this.outerRadius = undefined;
     this.pixelMargin = 0;
     this.fullCircles = 0;
+    this.circular = false;
 
     if (cfg) {
       Object.assign(this, cfg);
index ff16b8be54616064836bcedeaa49e9d2bbf8157e..84274f26e04bad09b7a8e4dda2676cfac0dded45 100644 (file)
@@ -49,6 +49,7 @@ export {
   ElementOptionsByType,
   ChartDatasetProperties,
   UpdateModeEnum,
+  ArcSpacingMode,
   registerables
 } from './types/index.js';
 export * from './types/index.js';
index 911b4cb2bc8dee3780bce98bb719084a6d72e1fa..32831adc88c520f1dd5daf10c2bbbd33a48a8a5b 100644 (file)
@@ -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 {
index 9bb4415ad1bf331265f38d1d4df8d5fd55f1e897..d485f4dcc19de0eb390bb8fde6aa44dbb592b73e 100644 (file)
@@ -9,7 +9,7 @@ module.exports = {
           backgroundColor: 'transparent',
           borderColor: '#000',
           borderWidth: 10,
-          spacing: 50,
+          spacing: 20,
         },
       ]
     },
index ecb2a2de3e9881022f74660a6f7f13318b266a42..a3f61ed74d487a662634185a06482706062f5955 100644 (file)
Binary files a/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.png and b/test/fixtures/controller.doughnut/borderJoinStyle/bevel-default.png differ
index f50e923c085e13569a064c9f09444ab9e9879633..b8223542f77541ac85180366500c4284a25f801e 100644 (file)
@@ -10,7 +10,7 @@ module.exports = {
           borderColor: '#000',
           borderJoinStyle: 'miter',
           borderWidth: 10,
-          spacing: 50,
+          spacing: 20,
         },
       ]
     },
index 6ec65b1f03143ef6c89ec1f45d0de3ebe865663d..a8bf39fc5df172db08fac6567aee7a36f9495cfe 100644 (file)
Binary files a/test/fixtures/controller.doughnut/borderJoinStyle/miter.png and b/test/fixtures/controller.doughnut/borderJoinStyle/miter.png differ
index 43aa7ca6c5101df451574498e4053b398e3f4dd7..baa2cb9484a313f2263697dafc8e99c036c86d78 100644 (file)
@@ -10,7 +10,7 @@ module.exports = {
           borderColor: '#000',
           borderJoinStyle: 'round',
           borderWidth: 10,
-          spacing: 50,
+          spacing: 20,
         },
       ]
     },
index dab62871e83c92991a55d2595d5221258a7dbaa7..01a715f34ee1ccb905120ccd3ee1017d719fb200 100644 (file)
Binary files a/test/fixtures/controller.doughnut/borderJoinStyle/round.png and b/test/fixtures/controller.doughnut/borderJoinStyle/round.png differ
index d2e0c59b07a147863e960b89ee8c2d53f2561b74..0efb3a3a79431918f23564ec96da8501ee9d01a7 100644 (file)
@@ -22,7 +22,7 @@ module.exports = {
       ],
     },
     options: {
-      spacing: 50,
+      spacing: 20,
       offset: [0, 50, 0, 0, 0],
     }
   }
index 0a68820fa0155f0f66b8407b13f9432f03ab0cbd..638bd2de81ab7c2807873b4b27a3279a3ea1710a 100644 (file)
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 (file)
index 0000000..b4985a4
--- /dev/null
@@ -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 (file)
index 0000000..af123fc
Binary files /dev/null and b/test/fixtures/controller.doughnut/doughnut-spacing-parallel.png differ
index c6c84379231bce8bf5ae317cb70fad882951a35d..b5c9c5c4e11a0ee686c0f385ab6885e381bec3b8 100644 (file)
@@ -22,7 +22,7 @@ module.exports = {
       ],
     },
     options: {
-      spacing: 50,
+      spacing: 20,
     }
   }
 };
index d586621e9809944bd4bf08edf84286a990ccdb0d..14f6d546cb2cd5e5d324165ce6dd647209d1468a 100644 (file)
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 (file)
index 0000000..16332d9
--- /dev/null
@@ -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 (file)
index 0000000..8575e13
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 (file)
index 0000000..4e7a1e8
--- /dev/null
@@ -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 (file)
index 0000000..b9ab0aa
Binary files /dev/null and b/test/fixtures/controller.doughnut/selfJoin/single-slice-360-border-selfjoin.png differ
index a4596b69024ba2134142d225281ce12d8214d2b6..ff45ab83138db154e8c58fc8b43ff89677f88f4a 100644 (file)
@@ -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',
index 394cdb57340217b378eed996455eab5d0c007515..0ba65e35efb12e0faa663b16e020ec53822a50e2 100644 (file)
@@ -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',
index 63d20caaec4819de51a1f51daa51dfe782dfda23..b832ac8ba6717967a43ecd020a730974bb0f5730 100644 (file)
@@ -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();
+  });
 });
index 21d0ccf17c770dcdc1eaeeac652a67adad13ce88..b848475df54ade2ec20adc2382a2d382d54ae228 100644 (file)
@@ -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<ChartType> = doughnutOptions;