import Element from '../core/core.element'; import {_angleBetween, getAngleFromPoint, TAU, HALF_PI, valueOrDefault} from '../helpers/index'; import {PI, _isBetween, _limitValue} from '../helpers/helpers.math'; import {_readValueToProps} from '../helpers/helpers.options'; function clipArc(ctx, element, endAngle) { 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 * @param {ArcElement} arc * @param {number} innerRadius * @param {number} outerRadius * @param {number} angleDelta Arc circumference in radians * @returns */ function parseBorderRadius(arc, innerRadius, outerRadius, angleDelta) { 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) * @param {number} r Radius from center point * @param {number} theta Angle in radians * @param {number} x Center X coordinate * @param {number} y Center Y coordinate * @returns {{ x: number; y: number }} Rectangular coordinate point */ function rThetaToXY(r, theta, x, y) { return { x: x + r * Math.cos(theta), y: y + r * Math.sin(theta), }; } /** * Path the arc, respecting the border radius * * 8 points of interest exist around the arc segment. * These points define the intersection of the arc edges and the corners. * * Start End * * 1---------2 Outer * / \ * 8 3 * | | * | | * 7 4 * \ / * 6---------5 Inner * @param {CanvasRenderingContext2D} ctx * @param {ArcElement} element */ function pathArc(ctx, element, offset, spacing, end, circular) { 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 segment from point 1 to point 2 ctx.arc(x, y, outerRadius, outerStartAdjustedAngle, 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 6 ctx.arc(x, y, innerRadius, endAngle - (innerEnd / innerRadius), 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, element, offset, spacing, circular) { const {fullCircles, startAngle, circumference} = element; let endAngle = element.endAngle; if (fullCircles) { pathArc(ctx, element, offset, spacing, startAngle + TAU, circular); for (let i = 0; i < fullCircles; ++i) { ctx.fill(); } if (!isNaN(circumference)) { endAngle = startAngle + circumference % TAU; if (circumference % TAU === 0) { endAngle += TAU; } } } pathArc(ctx, element, offset, spacing, endAngle, circular); ctx.fill(); return endAngle; } function drawFullCircleBorders(ctx, element, inner) { const {x, y, startAngle, pixelMargin, fullCircles} = element; const outerRadius = Math.max(element.outerRadius - pixelMargin, 0); const innerRadius = element.innerRadius + pixelMargin; let i; if (inner) { clipArc(ctx, element, startAngle + TAU); } ctx.beginPath(); ctx.arc(x, y, innerRadius, startAngle + TAU, startAngle, true); for (i = 0; i < fullCircles; ++i) { ctx.stroke(); } ctx.beginPath(); ctx.arc(x, y, outerRadius, startAngle, startAngle + TAU); for (i = 0; i < fullCircles; ++i) { ctx.stroke(); } } function drawBorder(ctx, element, offset, spacing, endAngle, circular) { const {options} = element; const {borderWidth, borderJoinStyle} = options; const inner = options.borderAlign === 'inner'; if (!borderWidth) { return; } if (inner) { ctx.lineWidth = borderWidth * 2; ctx.lineJoin = borderJoinStyle || 'round'; } else { ctx.lineWidth = borderWidth; ctx.lineJoin = borderJoinStyle || 'bevel'; } if (element.fullCircles) { drawFullCircleBorders(ctx, element, inner); } if (inner) { clipArc(ctx, element, endAngle); } pathArc(ctx, element, offset, spacing, endAngle, circular); ctx.stroke(); } export default class ArcElement extends Element { 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); } } /** * @param {number} chartX * @param {number} chartY * @param {boolean} [useFinalPosition] */ inRange(chartX, chartY, useFinalPosition) { 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 / 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); } /** * @param {boolean} [useFinalPosition] */ getCenterPoint(useFinalPosition) { const {x, y, startAngle, endAngle, innerRadius, outerRadius} = this.getProps([ 'x', 'y', 'startAngle', 'endAngle', 'innerRadius', 'outerRadius', 'circumference', ], 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 }; } /** * @param {boolean} [useFinalPosition] */ tooltipPosition(useFinalPosition) { return this.getCenterPoint(useFinalPosition); } draw(ctx) { const {options, circumference} = this; const offset = (options.offset || 0) / 2; 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(); let radiusOffset = 0; if (offset) { radiusOffset = offset / 2; const halfAngle = (this.startAngle + this.endAngle) / 2; ctx.translate(Math.cos(halfAngle) * radiusOffset, Math.sin(halfAngle) * radiusOffset); if (this.circumference >= PI) { radiusOffset = offset; } } ctx.fillStyle = options.backgroundColor; ctx.strokeStyle = options.borderColor; const endAngle = drawArc(ctx, this, radiusOffset, spacing, circular); drawBorder(ctx, this, radiusOffset, spacing, endAngle, circular); ctx.restore(); } } ArcElement.id = 'arc'; /** * @type {any} */ ArcElement.defaults = { borderAlign: 'center', borderColor: '#fff', borderJoinStyle: undefined, borderRadius: 0, borderWidth: 2, offset: 0, spacing: 0, angle: undefined, circular: true, }; /** * @type {any} */ ArcElement.defaultRoutes = { backgroundColor: 'backgroundColor' };