Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 25 additions & 24 deletions docs/charts/doughnut.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions docs/charts/polar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 5 additions & 2 deletions src/controllers/controller.doughnut.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
};

/**
Expand Down
10 changes: 10 additions & 0 deletions src/controllers/controller.polarArea.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export default class PolarAreaController extends DatasetController {
},
indexAxis: 'r',
startAngle: 0,
spacing: 0,
spacingMode: 'proportional',
};

/**
Expand Down Expand Up @@ -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);
}
}
Expand Down
126 changes: 101 additions & 25 deletions src/elements/element.arc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -122,39 +144,88 @@ 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
// So we adjust the start and end angle of the arc such that
// 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();

Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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,
Expand All @@ -332,6 +406,7 @@ export default class ArcElement extends Element<ArcProps, ArcOptions> {
outerRadius: number;
pixelMargin: number;
startAngle: number;
circular: boolean;

constructor(cfg) {
super();
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export {
ElementOptionsByType,
ChartDatasetProperties,
UpdateModeEnum,
ArcSpacingMode,
registerables
} from './types/index.js';
export * from './types/index.js';
Loading
Loading