src/elements/element.arc.ts
import Element from '../core/core.element.js';
import {_angleBetween, getAngleFromPoint, TAU, HALF_PI, valueOrDefault} from '../helpers/index.js';
import {PI, _isBetween, _limitValue} from '../helpers/helpers.math.js';
import {_readValueToProps} from '../helpers/helpers.options.js';
import type {ArcOptions, Point} from '../types/index.js';
function clipArc(ctx: CanvasRenderingContext2D, element: ArcElement, endAngle: number) {
const {startAngle, pixelMargin, x, y, outerRadius, innerRadius} = element;
let angleMargin = pixelMargin / outerRadius;
// Draw an inner border by clipping the arc and drawing a double-width border
// Enlarge the clipping arc by 0.33 pixels to eliminate glitches between borders
ctx.beginPath();
ctx.arc(x, y, outerRadius, startAngle - angleMargin, endAngle + angleMargin);
if (innerRadius > pixelMargin) {
angleMargin = pixelMargin / innerRadius;
ctx.arc(x, y, innerRadius, endAngle + angleMargin, startAngle - angleMargin, true);
} else {
ctx.arc(x, y, pixelMargin, endAngle + HALF_PI, startAngle - HALF_PI);
}
ctx.closePath();
ctx.clip();
}
function toRadiusCorners(value) {
return _readValueToProps(value, ['outerStart', 'outerEnd', 'innerStart', 'innerEnd']);
}
/**
* Parse border radius from the provided options
*/
function parseBorderRadius(arc: ArcElement, innerRadius: number, outerRadius: number, angleDelta: number) {
const o = toRadiusCorners(arc.options.borderRadius);
const halfThickness = (outerRadius - innerRadius) / 2;
const innerLimit = Math.min(halfThickness, angleDelta * innerRadius / 2);
// Outer limits are complicated. We want to compute the available angular distance at
// a radius of outerRadius - borderRadius because for small angular distances, this term limits.
// We compute at r = outerRadius - borderRadius because this circle defines the center of the border corners.
//
// If the borderRadius is large, that value can become negative.
// This causes the outer borders to lose their radius entirely, which is rather unexpected. To solve that, if borderRadius > outerRadius
// we know that the thickness term will dominate and compute the limits at that point
const computeOuterLimit = (val) => {
const outerArcLimit = (outerRadius - Math.min(halfThickness, val)) * angleDelta / 2;
return _limitValue(val, 0, Math.min(halfThickness, outerArcLimit));
};
return {
outerStart: computeOuterLimit(o.outerStart),
outerEnd: computeOuterLimit(o.outerEnd),
innerStart: _limitValue(o.innerStart, 0, innerLimit),
innerEnd: _limitValue(o.innerEnd, 0, innerLimit),
};
}
/**
* Convert (r, 𝜃) to (x, y)
*/
function rThetaToXY(r: number, theta: number, x: number, y: number) {
return {
x: x + r * Math.cos(theta),
y: y + r * Math.sin(theta),
};
}
/**
* Path the arc, respecting border radius by separating into left and right halves.
*
* Start End
*
* 1--->a--->2 Outer
* / \
* 8 3
* | |
* | |
* 7 4
* \ /
* 6<---b<---5 Inner
*/
function pathArc(
ctx: CanvasRenderingContext2D,
element: ArcElement,
offset: number,
spacing: number,
end: number,
circular: boolean,
) {
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;
let spacingOffset = 0;
const alpha = end - start;
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 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 outerStartAdjustedRadius = outerRadius - outerStart;
const outerEndAdjustedRadius = outerRadius - outerEnd;
const outerStartAdjustedAngle = startAngle + outerStart / outerStartAdjustedRadius;
const outerEndAdjustedAngle = endAngle - outerEnd / outerEndAdjustedRadius;
const innerStartAdjustedRadius = innerRadius + innerStart;
const innerEndAdjustedRadius = innerRadius + innerEnd;
const innerStartAdjustedAngle = startAngle + innerStart / innerStartAdjustedRadius;
const innerEndAdjustedAngle = endAngle - innerEnd / innerEndAdjustedRadius;
ctx.beginPath();
if (circular) {
// The first arc segments from point 1 to point a to point 2
const outerMidAdjustedAngle = (outerStartAdjustedAngle + outerEndAdjustedAngle) / 2;
ctx.arc(x, y, outerRadius, outerStartAdjustedAngle, outerMidAdjustedAngle);
ctx.arc(x, y, outerRadius, outerMidAdjustedAngle, outerEndAdjustedAngle);
// 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);
}
// The line from point 3 to point 4
const p4 = rThetaToXY(innerEndAdjustedRadius, endAngle, 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);
}
// 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);
// 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);
}
// The line from point 7 to point 8
const p8 = rThetaToXY(outerStartAdjustedRadius, startAngle, 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);
}
} else {
ctx.moveTo(x, y);
const outerStartX = Math.cos(outerStartAdjustedAngle) * outerRadius + x;
const outerStartY = Math.sin(outerStartAdjustedAngle) * outerRadius + y;
ctx.lineTo(outerStartX, outerStartY);
const outerEndX = Math.cos(outerEndAdjustedAngle) * outerRadius + x;
const outerEndY = Math.sin(outerEndAdjustedAngle) * outerRadius + y;
ctx.lineTo(outerEndX, outerEndY);
}
ctx.closePath();
}
function drawArc(
ctx: CanvasRenderingContext2D,
element: ArcElement,
offset: number,
spacing: number,
circular: boolean,
) {
const {fullCircles, startAngle, circumference} = element;
let endAngle = element.endAngle;
if (fullCircles) {
pathArc(ctx, element, offset, spacing, endAngle, circular);
for (let i = 0; i < fullCircles; ++i) {
ctx.fill();
}
if (!isNaN(circumference)) {
endAngle = startAngle + (circumference % TAU || TAU);
}
}
pathArc(ctx, element, offset, spacing, endAngle, circular);
ctx.fill();
return endAngle;
}
function drawBorder(
ctx: CanvasRenderingContext2D,
element: ArcElement,
offset: number,
spacing: number,
circular: boolean,
) {
const {fullCircles, startAngle, circumference, options} = element;
const {borderWidth, borderJoinStyle, borderDash, borderDashOffset} = options;
const inner = options.borderAlign === 'inner';
if (!borderWidth) {
return;
}
ctx.setLineDash(borderDash || []);
ctx.lineDashOffset = borderDashOffset;
if (inner) {
ctx.lineWidth = borderWidth * 2;
ctx.lineJoin = borderJoinStyle || 'round';
} else {
ctx.lineWidth = borderWidth;
ctx.lineJoin = borderJoinStyle || 'bevel';
}
let endAngle = element.endAngle;
if (fullCircles) {
pathArc(ctx, element, offset, spacing, endAngle, circular);
for (let i = 0; i < fullCircles; ++i) {
ctx.stroke();
}
if (!isNaN(circumference)) {
endAngle = startAngle + (circumference % TAU || TAU);
}
}
if (inner) {
clipArc(ctx, element, endAngle);
}
if (!fullCircles) {
pathArc(ctx, element, offset, spacing, endAngle, circular);
ctx.stroke();
}
}
export interface ArcProps extends Point {
startAngle: number;
endAngle: number;
innerRadius: number;
outerRadius: number;
circumference: number;
}
export default class ArcElement extends Element<ArcProps, ArcOptions> {
static id = 'arc';
static defaults = {
borderAlign: 'center',
borderColor: '#fff',
borderDash: [],
borderDashOffset: 0,
borderJoinStyle: undefined,
borderRadius: 0,
borderWidth: 2,
offset: 0,
spacing: 0,
angle: undefined,
circular: true,
};
static defaultRoutes = {
backgroundColor: 'backgroundColor'
};
static descriptors = {
_scriptable: true,
_indexable: (name) => name !== 'borderDash'
};
circumference: number;
endAngle: number;
fullCircles: number;
innerRadius: number;
outerRadius: number;
pixelMargin: number;
startAngle: number;
constructor(cfg) {
super();
this.options = undefined;
this.circumference = undefined;
this.startAngle = undefined;
this.endAngle = undefined;
this.innerRadius = undefined;
this.outerRadius = undefined;
this.pixelMargin = 0;
this.fullCircles = 0;
if (cfg) {
Object.assign(this, cfg);
}
}
inRange(chartX: number, chartY: number, useFinalPosition: boolean) {
const point = this.getProps(['x', 'y'], useFinalPosition);
const {angle, distance} = getAngleFromPoint(point, {x: chartX, y: chartY});
const {startAngle, endAngle, innerRadius, outerRadius, circumference} = this.getProps([
'startAngle',
'endAngle',
'innerRadius',
'outerRadius',
'circumference'
], useFinalPosition);
const rAdjust = (this.options.spacing + this.options.borderWidth) / 2;
const _circumference = valueOrDefault(circumference, endAngle - startAngle);
const betweenAngles = _circumference >= TAU || _angleBetween(angle, startAngle, endAngle);
const withinRadius = _isBetween(distance, innerRadius + rAdjust, outerRadius + rAdjust);
return (betweenAngles && withinRadius);
}
getCenterPoint(useFinalPosition: boolean) {
const {x, y, startAngle, endAngle, innerRadius, outerRadius} = this.getProps([
'x',
'y',
'startAngle',
'endAngle',
'innerRadius',
'outerRadius'
], useFinalPosition);
const {offset, spacing} = this.options;
const halfAngle = (startAngle + endAngle) / 2;
const halfRadius = (innerRadius + outerRadius + spacing + offset) / 2;
return {
x: x + Math.cos(halfAngle) * halfRadius,
y: y + Math.sin(halfAngle) * halfRadius
};
}
tooltipPosition(useFinalPosition: boolean) {
return this.getCenterPoint(useFinalPosition);
}
draw(ctx: CanvasRenderingContext2D) {
const {options, circumference} = this;
const offset = (options.offset || 0) / 4;
const spacing = (options.spacing || 0) / 2;
const circular = options.circular;
this.pixelMargin = (options.borderAlign === 'inner') ? 0.33 : 0;
this.fullCircles = circumference > TAU ? Math.floor(circumference / TAU) : 0;
if (circumference === 0 || this.innerRadius < 0 || this.outerRadius < 0) {
return;
}
ctx.save();
const halfAngle = (this.startAngle + this.endAngle) / 2;
ctx.translate(Math.cos(halfAngle) * offset, Math.sin(halfAngle) * offset);
const fix = 1 - Math.sin(Math.min(PI, circumference || 0));
const radiusOffset = offset * fix;
ctx.fillStyle = options.backgroundColor;
ctx.strokeStyle = options.borderColor;
drawArc(ctx, this, radiusOffset, spacing, circular);
drawBorder(ctx, this, radiusOffset, spacing, circular);
ctx.restore();
}
}