apexcharts
Version:
A JavaScript Chart Library
1,926 lines • 65.1 kB
JavaScript
// @ts-check
import Animations from '../modules/Animations'
import Fill from '../modules/Fill'
import Utils from '../utils/Utils'
import Graphics from '../modules/Graphics'
import Filters from '../modules/Filters'
import Scales from '../modules/Scales'
import Helpers from './common/circle/Helpers'
import { spaceOutLabels } from './common/OuterLabels'
import { Environment } from '../utils/Environment'
import {
roundedDonutSegmentPath,
roundedPieSegmentPath,
sharpDonutSegmentPath,
} from './common/arc/ArcPath'
/**
* ApexCharts Pie Class for drawing Pie / Donut Charts.
* @module Pie
**/
/**
* Slide of a clicked slice out of the pie, and fade of the hover outline band.
* Both are CSS transitions rather than JS tweens: the slide only ever changes
* a `transform` and the fade only an `opacity`, so the compositor can run them
* without touching the path geometry. Applied inline (not from the stylesheet)
* so a build served without apexcharts.css still animates, and still hides the
* band by default.
*/
const SLICE_OFFSET_TRANSITION = 'transform 320ms cubic-bezier(0.25, 0.8, 0.3, 1)'
const HOVER_OUTLINE_TRANSITION = 'opacity 180ms ease-out'
class Pie {
/**
* @param {import('../types/internal').ChartStateW} w
* @param {import('../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.ctx = ctx
this.w = w
this.chartType = this.w.config.chart.type
this.initialAnim = this.w.config.chart.animations.enabled
this.dynamicAnim =
this.initialAnim &&
this.w.config.chart.animations.dynamicAnimation.enabled
this.animBeginArr = [0]
this.animDur = 0
this.donutDataLabels = this.w.config.plotOptions.pie.donut.labels
this.lineColorArr =
w.globals.stroke.colors !== undefined
? w.globals.stroke.colors
: w.globals.colors
this.defaultSize = Math.min(w.layout.gridWidth, w.layout.gridHeight)
this.centerY = this.defaultSize / 2
this.centerX = w.layout.gridWidth / 2
if (w.config.chart.type === 'radialBar') {
this.fullAngle = 360
} else {
this.fullAngle = Math.abs(
w.config.plotOptions.pie.endAngle - w.config.plotOptions.pie.startAngle,
)
}
this.initialAngle = w.config.plotOptions.pie.startAngle % this.fullAngle
w.globals.radialSize =
this.defaultSize / 2.05 -
w.config.stroke.width -
(!w.config.chart.sparkline.enabled ? w.config.chart.dropShadow.blur : 0)
// Outer name labels (category name + leader line). Resolve config + the
// font used for the names, then shrink the radius so the labels and their
// connectors fit without clipping (see reserveExternalLabelSpace).
this.externalCfg = w.config.plotOptions.pie.dataLabels.external
const dlStyle = w.config.dataLabels.style
this.externalLabelStyle = {
fontSize: this.externalCfg.fontSize || dlStyle.fontSize,
fontFamily: this.externalCfg.fontFamily || dlStyle.fontFamily,
fontWeight: this.externalCfg.fontWeight || dlStyle.fontWeight,
}
/** @type {any[]} collected outer-label layout, drawn after de-overlap */
this.externalLabels = []
this.externalLabelMaxLines = 1
this.externalLabelLineH = parseFloat(this.externalLabelStyle.fontSize) || 12
w.globals.pieExternalLabelMarginY = 0
// Outer name labels are only meaningful for pie/donut, where every slice
// reaches the same outer edge. polarArea encodes value as the radial
// length, so a rim-anchored leader line detaches from short slices and
// reads like an axis label — skip it there.
this.showExternalLabels = this.externalCfg.show && this.chartType !== 'polarArea'
if (this.showExternalLabels && !w.globals.noData) {
this.reserveExternalLabelSpace()
}
this.donutSize =
(w.globals.radialSize *
parseInt(w.config.plotOptions.pie.donut.size, 10)) /
100
const scaleSize = w.config.plotOptions.pie.customScale
const halfW = w.layout.gridWidth / 2
const halfH = w.layout.gridHeight / 2
this.translateX = halfW - halfW * scaleSize
this.translateY = halfH - halfH * scaleSize
this.dataLabelsGroup = new Graphics(this.w).group({
class: 'apexcharts-datalabels-group',
transform: `translate(${this.translateX}, ${this.translateY}) scale(${scaleSize})`,
})
this.maxY = 0
/** @type {any} */
/** @type {any[]} */
this.sliceLabels = []
/** @type {any} */
this.sliceSizes = []
// polarArea only: previous render's slice radii, so a data change can
// animate the value channel (the radius) from where it was.
/** @type {number[]} */
this.prevSliceSizes = []
// Everything that has to travel with a slice when it slides out of the pie
// on click, keyed by slice index: its inner percentage label and its outer
// name label (each drawn into its own group outside the slice's own group,
// so they paint above every slice). See offsetSlice.
/** @type {Record<number, SVGElement>} */
this.sliceLabelGroups = {}
/** @type {Record<number, SVGElement>} */
this.externalLabelGroups = {}
// The single band re-plotted around whichever slice is hovered, plus the
// group holding it (created in drawArcs, kept empty until first hover).
/** @type {any} */
this.elHoverOutline = null
/** @type {any} */
this.elHoverOutlinePath = null
/** @type {number} slice the band is currently traced around, -1 for none */
this.hoverOutlineIndex = -1
/** @type {any} */
this.prevSectorAngleArr = [] // for dynamic animations
}
/**
* The text shown in an outer (name) label for slice `i`. Applies the
* user `name.formatter` if provided, otherwise the raw series name. The
* formatter may return a string or an array of strings (one per line, e.g.
* `[name, percent]`); normalize via `getExternalLabelLines`.
* @param {number} i
* @returns {string | string[]}
*/
getExternalLabelText(i) {
const w = this.w
const name = w.seriesData.seriesNames[i]
const fn = this.externalCfg.formatter
if (typeof fn === 'function') {
return fn(name, {
seriesIndex: i,
percent: w.globals.seriesPercent?.[i]?.[0],
value: w.globals.seriesTotals?.[i],
w,
})
}
return name == null ? '' : `${name}`
}
/**
* Outer label content for slice `i` normalized to an array of line strings.
* Supports a formatter returning an array, or a string with `\n` separators.
* @param {number} i
* @returns {string[]}
*/
getExternalLabelLines(i) {
const raw = this.getExternalLabelText(i)
const arr = Array.isArray(raw) ? raw : `${raw == null ? '' : raw}`.split('\n')
return arr.map((l) => (l == null ? '' : `${l}`))
}
/**
* Shrink the pie radius (and reposition its center) so outer name labels and
* their connector lines fit inside the chart area without clipping. Stores
* the reserved vertical band on `w.globals.pieExternalLabelMarginY` so
* Core.resizeNonAxisCharts can grow the SVG height to match.
*/
reserveExternalLabelSpace() {
const w = this.w
const helpers = new Helpers(w)
// Labels may be multi-line (e.g. name + percent). Measure every line and
// track the tallest label so the reserved bands fit a 2-line block etc.
const lineSets = (w.seriesData.seriesNames || []).map((_, i) =>
this.getExternalLabelLines(i),
)
const maxLabelWidth = helpers.getMaxLabelWidth(lineSets.flat(), {
fontSize: this.externalLabelStyle.fontSize,
fontFamily: this.externalLabelStyle.fontFamily,
})
this.externalLabelMaxLines = lineSets.reduce((m, s) => Math.max(m, s.length), 1)
this.externalLabelLineH = Math.round(
(parseFloat(this.externalLabelStyle.fontSize) || 12) * 1.35,
)
const cn = this.externalCfg.connector
const blockHeight = this.externalLabelMaxLines * this.externalLabelLineH
// horizontal band on each side: text + connector run/gap + breathing room
const mh = maxLabelWidth + (cn.length || 0) + (cn.gap || 0) + 12
// vertical band above/below: half the tallest label + connector gap + pad
const mv = blockHeight / 2 + (cn.gap || 0) + 6
const fitted = Math.min(
w.globals.radialSize,
w.layout.gridWidth / 2 - mh,
w.layout.gridHeight / 2 - mv,
)
// never collapse below a sane floor for very long labels / tiny charts
w.globals.radialSize = Math.max(fitted, this.defaultSize * 0.15)
w.globals.pieExternalLabelMarginY = mv
const heightStr = w.config.chart.height ? String(w.config.chart.height) : ''
const userSetFixedHeight = heightStr !== '' && heightStr !== 'auto'
// For a fixed / percent height the container is NOT resized to hug the pie
// (Core.resizeNonAxisCharts returns early), so center within gridHeight.
// For an auto height the container is grown to 2*(radius+mv), so place the
// center one band below the top.
this.centerY = userSetFixedHeight
? w.layout.gridHeight / 2
: w.globals.radialSize + mv
}
/**
* @param {any[]} series
*/
draw(series) {
const self = this
const w = this.w
const graphics = new Graphics(this.w)
const elPie = graphics.group({
class: 'apexcharts-pie',
})
if (w.globals.noData) return elPie
let total = 0
for (let k = 0; k < series.length; k++) {
// CALCULATE THE TOTAL
total += Utils.negToZero(series[k])
}
const sectorAngleArr = []
// el to which series will be drawn
const elSeries = graphics.group()
// prevent division by zero error if there is no data
if (total === 0) {
total = 0.00001
}
/**
* @param {number} m
*/
series.forEach((m) => {
this.maxY = Math.max(this.maxY, m)
})
// override maxY if user provided in config
if (w.config.yaxis[0].max) {
this.maxY = w.config.yaxis[0].max
}
if (w.config.grid.position === 'back' && this.chartType === 'polarArea') {
this.drawPolarElements(elPie)
}
// polarArea divides the circle by COUNT, so a legend-collapsed series must
// give its slot back: pie gets this for free (a collapsed value is 0, so
// its value-proportional angle is 0), but an equal share per series.length
// would leave a dead gap where the hidden slice was and render the rest of
// the circle broken. Slots belong to the visible slices only.
const collapsedIdx = w.globals.collapsedSeriesIndices || []
let polarVisible = 1
if (this.chartType === 'polarArea') {
let visible = 0
for (let k = 0; k < series.length; k++) {
if (collapsedIdx.indexOf(k) === -1) visible++
}
polarVisible = Math.max(1, visible)
}
for (let i = 0; i < series.length; i++) {
// CALCULATE THE ANGLES
const angle = (this.fullAngle * Utils.negToZero(series[i])) / total
sectorAngleArr.push(angle)
if (this.chartType === 'polarArea') {
sectorAngleArr[i] =
collapsedIdx.indexOf(i) > -1 ? 0 : this.fullAngle / polarVisible
// Floor the divisor: with grid.position:'front' the maxY reset runs
// after this loop, so an all-zero series would divide by 0 here and push
// NaN slice sizes into getPiePath (corrupt arcs). series[i] is 0 in that
// case anyway, so a denom of 1 yields a 0-size slice.
this.sliceSizes.push(
(w.globals.radialSize * series[i]) / (this.maxY || 1),
)
} else {
this.sliceSizes.push(w.globals.radialSize)
}
}
// Skip the previous-angle reconstruction when a cross-type morph is
// active: drawArcs will use the captured path directly via the morph
// feature, so the per-angle interpolation here would just compute
// garbage on previousPaths that came from a different chart family.
const morphActive = this.ctx.morphTypeChange?.isActive() === true
if (w.globals.dataChanged && !morphActive) {
if (this.chartType === 'polarArea') {
// polarArea angles are count-based, so the pie reconstruction below
// (value-proportional) would fabricate previous angles that never
// existed and every slice would sweep in from a wrong position. The
// draw stashes its real angles; use them, or fall back to an equal
// re-division when the stash cannot line up (e.g. the previous render
// was a different chart type).
const prevValues = w.globals.previousPaths
const stash = w.globals.prevPolarAngles
if (Array.isArray(stash) && stash.length === prevValues.length) {
this.prevSectorAngleArr = stash.slice()
} else {
for (let i = 0; i < prevValues.length; i++) {
this.prevSectorAngleArr.push(
this.fullAngle / Math.max(1, prevValues.length),
)
}
}
// The radius is the value channel, so it must animate from where it
// was, like a pie slice's angle does. Reconstruct the previous sizes
// from the previous values on the same scale rule the draw uses.
let prevMaxY = 0
for (let k = 0; k < prevValues.length; k++) {
prevMaxY = Math.max(prevMaxY, Utils.negToZero(prevValues[k]))
}
if (w.config.yaxis[0].max) {
prevMaxY = w.config.yaxis[0].max
}
this.prevSliceSizes = prevValues.map(
(/** @type {number} */ v) =>
(w.globals.radialSize * Utils.negToZero(v)) / (prevMaxY || 1),
)
} else {
let prevTotal = 0
for (let k = 0; k < w.globals.previousPaths.length; k++) {
// CALCULATE THE PREV TOTAL
prevTotal += Utils.negToZero(w.globals.previousPaths[k])
}
let previousAngle
for (let i = 0; i < w.globals.previousPaths.length; i++) {
// CALCULATE THE PREVIOUS ANGLES
previousAngle =
(this.fullAngle * Utils.negToZero(w.globals.previousPaths[i])) /
prevTotal
this.prevSectorAngleArr.push(previousAngle)
}
}
}
if (this.chartType === 'polarArea') {
// The stash the NEXT data-change animation will start from.
w.globals.prevPolarAngles = sectorAngleArr.slice()
}
// on small chart size after few count of resizes browser window donutSize can be negative
if (this.donutSize < 0) {
this.donutSize = 0
}
if (this.chartType === 'donut') {
// draw the inner circle and add some text to it
const circle = graphics.drawCircle(this.donutSize)
circle.attr({
cx: this.centerX,
cy: this.centerY,
fill: w.config.plotOptions.pie.donut.background
? w.config.plotOptions.pie.donut.background
: 'transparent',
})
elSeries.add(circle)
}
const elG = self.drawArcs(sectorAngleArr, series)
// add slice dataLabels at the end
this.sliceLabels.forEach((s) => {
elG.add(s)
})
elSeries.attr({
transform: `translate(${this.translateX}, ${this.translateY}) scale(${w.config.plotOptions.pie.customScale})`,
})
elSeries.add(elG)
elPie.add(elSeries)
if (this.donutDataLabels.show) {
// On initial mount with animations enabled, the center label starts
// hidden and fades in after the last slice finishes its sweep — so the
// total/center value lands *with* the chart instead of before it.
const shouldFadeInLabels =
this.initialAnim &&
!w.globals.resized &&
!w.globals.dataChanged &&
this.animDur > 0
const dataLabels = this.renderInnerDataLabels(
this.dataLabelsGroup,
this.donutDataLabels,
{
hollowSize: this.donutSize,
centerX: this.centerX,
centerY: this.centerY,
opacity: shouldFadeInLabels ? 0 : this.donutDataLabels.show,
},
)
if (shouldFadeInLabels) {
const labelsNode = this.dataLabelsGroup.node
labelsNode.style.transition = 'opacity 280ms ease-out'
setTimeout(() => {
labelsNode.style.opacity = '1'
}, this.animDur)
}
elPie.add(dataLabels)
}
if (w.config.grid.position === 'front' && this.chartType === 'polarArea') {
this.drawPolarElements(elPie)
}
return elPie
}
// core function for drawing pie arcs
/**
* @param {any[]} sectorAngleArr
* @param {any[]} series
*/
drawArcs(sectorAngleArr, series) {
const w = this.w
const filters = new Filters(this.w)
const graphics = new Graphics(this.w)
const fill = new Fill(this.w)
const g = graphics.group({
class: 'apexcharts-slices',
})
// First child of the slices group, so the hover band always paints *under*
// the slices: its inner edge can then tuck below a rim without showing a
// seam. Inert to the pointer, or a band lying over a slid-out slice would
// steal that slice's hover and flicker the effect it belongs to.
this.elHoverOutline = graphics.group({
class: 'apexcharts-pie-hover-outline',
})
this.elHoverOutline.node.style.pointerEvents = 'none'
this.elHoverOutline.node.style.opacity = '0'
// The group owns the fade and nothing else. Any offset rides on the band
// path inside it, so the two never have to be untangled from one another.
if (w.config.chart.animations.enabled) {
this.elHoverOutline.node.style.transition = HOVER_OUTLINE_TRANSITION
}
g.add(this.elHoverOutline)
let startAngle = this.initialAngle
let prevStartAngle = this.initialAngle
let endAngle = this.initialAngle
let prevEndAngle = this.initialAngle
this.strokeWidth = w.config.stroke.show ? w.config.stroke.width : 0
const morphActive = this.ctx.morphTypeChange?.isActive() === true
for (let i = 0; i < sectorAngleArr.length; i++) {
const elPieArc = graphics.group({
class: `apexcharts-series apexcharts-pie-series`,
seriesName: Utils.escapeString(w.seriesData.seriesNames[i]),
rel: i + 1,
'data:realIndex': i,
})
g.add(elPieArc)
startAngle = endAngle
prevStartAngle = prevEndAngle
endAngle = startAngle + sectorAngleArr[i]
prevEndAngle = prevStartAngle + this.prevSectorAngleArr[i]
const angle =
endAngle < startAngle
? this.fullAngle + endAngle - startAngle
: endAngle - startAngle
const pathFill = fill.fillPath({
seriesNumber: i,
size: this.sliceSizes[i],
value: series[i],
}) // additionally, pass size for gradient drawing in the fillPath function
// For a cross-type morph, the initial path comes from the captured
// outgoing snapshot (bar rect, radialBar arc, etc.). The SVGAnimation
// .plot() chain below will interpolate that into the final pie path
// via the existing morphPaths engine. Falls back to getChangedPath
// for normal data-change updates when no morph is queued.
const morphFrom = morphActive
? this.ctx.morphTypeChange.getInitialPathFor(i, 0)
: null
// polarArea's pre-animation frame must sit at the PREVIOUS radius too,
// not just the previous angles: its radius is the value channel.
const prevSize =
this.chartType === 'polarArea' &&
w.globals.dataChanged &&
!morphActive
? this.prevSliceSizes[i] || 0
: undefined
const path =
morphFrom || this.getChangedPath(prevStartAngle, prevEndAngle, prevSize)
const elPath = graphics.drawPath({
d: path,
// Pie/donut/polarArea data is a single series, so a user-supplied
// `stroke.colors` shorter than the slice count is NOT padded by the
// theme engine (unlike fill colors, which cycle). Without this, only
// slice 0 gets the requested color and the rest fall back to a grey
// default. Cycle the array — matching fill-color behaviour — so a
// single `stroke.colors: ['#fff']` borders every slice as expected.
stroke: Array.isArray(this.lineColorArr)
? this.lineColorArr[i] ??
this.lineColorArr[i % this.lineColorArr.length]
: this.lineColorArr,
strokeWidth: 0,
fill: pathFill,
fillOpacity: w.config.fill.opacity,
classes: `apexcharts-pie-area apexcharts-${this.chartType.toLowerCase()}-slice-${i}`,
})
elPath.attr({
index: 0,
j: i,
})
filters.setSelectionFilter(elPath, 0, i)
if (w.config.chart.dropShadow.enabled) {
const shadow = w.config.chart.dropShadow
filters.dropShadow(elPath, shadow, i)
}
this.addListeners(elPath, this.donutDataLabels, i)
let labelPosition = {
x: 0,
y: 0,
}
const midAngle = (startAngle + angle / 2) % this.fullAngle
let arcCenter = { x: this.centerX, y: this.centerY }
if (this.chartType === 'pie' || this.chartType === 'polarArea') {
labelPosition = Utils.polarToCartesian(
this.centerX,
this.centerY,
w.globals.radialSize / 1.25 +
w.config.plotOptions.pie.dataLabels.offset,
midAngle,
)
arcCenter = Utils.polarToCartesian(
this.centerX,
this.centerY,
w.globals.radialSize / 2,
midAngle,
)
} else if (this.chartType === 'donut') {
labelPosition = Utils.polarToCartesian(
this.centerX,
this.centerY,
(w.globals.radialSize + this.donutSize) / 2 +
w.config.plotOptions.pie.dataLabels.offset,
midAngle,
)
arcCenter = Utils.polarToCartesian(
this.centerX,
this.centerY,
(w.globals.radialSize + this.donutSize) / 2,
midAngle,
)
}
Graphics.setAttrs(elPath.node, {
'data:angle': angle,
'data:startAngle': startAngle,
'data:strokeWidth': this.strokeWidth,
'data:value': series[i],
'data:cx': arcCenter.x,
'data:cy': arcCenter.y,
})
elPieArc.add(elPath)
// Cross-type morph, objects -> wedge: the piece layer needs this mark's
// FINAL geometry, and a slice's live `d` is wherever its own animation
// currently has it. Stamp the final path for the engine to read, and
// only while a morph is active so a normal render pays nothing.
let pieceClaimed = false
if (morphActive) {
const finalD = this.getPiePath({
me: this,
startAngle,
angle,
size: this.sliceSizes[i],
})
elPath.node.setAttribute('data:pathFinal', finalD)
pieceClaimed =
this.ctx.morphTypeChange.claimsTargetMark?.(i, 0) === true
if (pieceClaimed) {
// Born final and held hidden: the flying dots tile this wedge and
// reveal it when the mosaic closes, so it must not animate itself
// underneath them (a bar that did replayed its grow as a bounce).
elPath.attr({ d: finalD })
elPath.node.setAttribute('data:pathOrig', finalD)
elPath.node.setAttribute('opacity', '0')
elPath.node.setAttribute('data-piece-hidden', '1')
}
}
// Animation code starts
let dur = 0
if (this.initialAnim && !w.globals.resized && !w.globals.dataChanged) {
dur = (angle / this.fullAngle) * w.config.chart.animations.speed
if (dur === 0) dur = 1
this.animDur = dur + this.animDur
this.animBeginArr.push(this.animDur)
} else {
this.animBeginArr.push(0)
}
if (pieceClaimed) {
// Nothing to animate here: the pieces own every pixel of this
// transition and the reveal is the engine's to make. (animBeginArr
// was already pushed above, once per slice, for every branch.)
} else if (morphActive && morphFrom) {
// Cross-type morph: bypass the angle-based animateArc loop and let
// SVGAnimation's morphPaths interpolate from the captured outgoing
// path to the final pie/donut/polarArea arc directly.
const targetD = this.getPiePath({
me: this,
startAngle,
angle,
size: this.sliceSizes[i],
})
const morphSpeed = this.ctx.morphTypeChange.getSpeed()
const animations = this.ctx.animations
elPath.node.setAttribute('data:pathOrig', targetD)
const morphRunner = elPath
.animate(morphSpeed)
.plot(targetD, 'polygons')
.attr({ 'stroke-width': this.strokeWidth })
// The angle-based branches flag animationCompleted via animateArc; the
// morph branch must do the same or w.globals.animationEnded stays false
// after a cross-type morph. animationCompleted is idempotent, so firing
// it per slice is safe.
if (morphRunner && typeof morphRunner.after === 'function') {
morphRunner.after(() => animations.animationCompleted(elPath))
} else {
animations.animationCompleted(elPath)
}
} else if (this.dynamicAnim && w.globals.dataChanged) {
this.animatePaths(elPath, {
size: this.sliceSizes[i],
prevSize,
endAngle,
startAngle,
prevStartAngle,
prevEndAngle,
animateStartingPos: true,
i,
animBeginArr: this.animBeginArr,
shouldSetPrevPaths: true,
dur: w.config.chart.animations.dynamicAnimation.speed,
})
} else {
this.animatePaths(elPath, {
size: this.sliceSizes[i],
endAngle,
startAngle,
i,
totalItems: sectorAngleArr.length - 1,
animBeginArr: this.animBeginArr,
dur,
})
}
// animation code ends
// One condition for "does a click move this slice", so the mouseup wiring
// can never disagree with getExpandOffset about it (drilldown, polarArea
// and expandOffset: 0 all land here).
if (this.getExpandOffset() > 0) {
elPath.node.addEventListener('mouseup', this.pieClicked.bind(this, i))
} else if (i === 0 && Filters.drilldownBlocksSliceOffset(w)) {
this.ctx.drilldown?.warnSliceOffsetDisabled()
}
if (
typeof w.interact.selectedDataPoints[0] !== 'undefined' &&
w.interact.selectedDataPoints[0].indexOf(i) > -1
) {
// Defer the "pulled out" offset for pre-selected slices until after
// the sweep finishes. Otherwise the slice translates while it's still
// growing, which makes both motions hard to read. Parked instantly
// rather than slid, since nobody clicked: a slice that is *already*
// selected should just be where it belongs by the time it is seen.
if (
this.initialAnim &&
!w.globals.resized &&
!w.globals.dataChanged &&
this.animDur > 0
) {
const _this = this
const _i = i
setTimeout(() => _this.pieClicked(_i, { animate: false }), this.animDur)
} else {
this.pieClicked(i, { animate: false })
}
}
if (w.config.dataLabels.enabled) {
const xPos = labelPosition.x
const yPos = labelPosition.y
let text = (100 * angle) / this.fullAngle + '%'
if (
angle !== 0 &&
w.config.plotOptions.pie.dataLabels.minAngleToShowLabel <
sectorAngleArr[i]
) {
const formatter = w.config.dataLabels.formatter
if (formatter !== undefined) {
text = formatter(w.globals.seriesPercent[i][0], {
seriesIndex: i,
w,
})
}
const foreColor = w.globals.dataLabels.style.colors[i]
const elPieLabelWrap = graphics.group({
class: `apexcharts-datalabels`,
})
const elPieLabel = graphics.drawText({
x: xPos,
y: yPos,
text,
textAnchor: 'middle',
fontSize: w.config.dataLabels.style.fontSize,
fontFamily: w.config.dataLabels.style.fontFamily,
fontWeight: w.config.dataLabels.style.fontWeight,
foreColor,
})
elPieLabelWrap.add(elPieLabel)
if (w.config.dataLabels.dropShadow.enabled) {
const textShadow = w.config.dataLabels.dropShadow
filters.dropShadow(elPieLabel, textShadow)
}
elPieLabel.node.classList.add('apexcharts-pie-label')
if (
w.config.chart.animations.animate &&
w.globals.resized === false
) {
elPieLabel.node.classList.add('apexcharts-pie-label-delay')
elPieLabel.node.style.animationDelay =
w.config.chart.animations.speed / 940 + 's'
}
this.sliceLabels.push(elPieLabelWrap)
this.sliceLabelGroups[i] = elPieLabelWrap.node
}
}
// Outer (name) label: collect geometry now, draw after the loop so the
// de-overlap pass can space crowded labels before they hit the DOM.
if (this.showExternalLabels && angle !== 0) {
const lines = this.getExternalLabelLines(i)
if (lines.some((l) => l !== '')) {
const anchor = Utils.polarToCartesian(
this.centerX,
this.centerY,
w.globals.radialSize,
midAngle,
)
const elbow = Utils.polarToCartesian(
this.centerX,
this.centerY,
w.globals.radialSize + (this.externalCfg.connector.gap || 0),
midAngle,
)
const isRight = elbow.x >= this.centerX
const baseLabelX = isRight
? elbow.x + (this.externalCfg.connector.length || 0)
: elbow.x - (this.externalCfg.connector.length || 0)
this.externalLabels.push({
i,
lines,
anchor,
elbow,
side: isRight ? 'right' : 'left',
labelX: baseLabelX + parseFloat(this.externalCfg.offsetX || 0),
idealY: elbow.y + parseFloat(this.externalCfg.offsetY || 0),
connectorColor: this.externalCfg.connector.color || w.globals.colors[i],
foreColor: this.externalCfg.color || w.config.chart.foreColor,
})
}
}
}
if (this.showExternalLabels && this.externalLabels.length) {
this.placeExternalLabels()
// External labels + connectors are overlays on top of the animated slice
// sweep, so reveal them gradually once the sweep finishes instead of
// popping in instantly — same mechanism as markers / data labels
// (delayedElements + apexcharts-element-hidden, faded back in by
// Animations.showDelayedElements on animationCompleted). Only arm this
// when a sweep that actually fires animationCompleted will run — matching
// the three animating branches below (morph, dynamic data-change, initial
// mount). For resize / animations-off / SSR (no animationCompleted) the
// labels must render visible so they don't stay stuck hidden.
const revealOnAnimEnd =
Environment.isBrowser() &&
(morphActive ||
(this.dynamicAnim && w.globals.dataChanged) ||
(this.initialAnim && !w.globals.resized && !w.globals.dataChanged))
this.externalLabels.forEach((lbl) => {
const group = new Helpers(w).drawExternalLabel({
lines: lbl.lines,
lineHeight: this.externalLabelLineH,
anchor: lbl.anchor,
elbow: lbl.elbow,
labelX: lbl.labelX,
labelY: lbl.labelY,
side: lbl.side,
connector: {
show: this.externalCfg.connector.show,
width: this.externalCfg.connector.width,
color: lbl.connectorColor,
},
style: this.externalLabelStyle,
foreColor: lbl.foreColor,
})
if (revealOnAnimEnd) {
group.node.classList.add('apexcharts-element-hidden')
w.globals.delayedElements.push({ el: group.node })
}
// Ride along when the slice slides out: the connector is anchored on
// the rim, so moving both by the same vector keeps it attached.
this.externalLabelGroups[lbl.i] = group.node
g.add(group)
})
}
return g
}
/**
* Vertical de-overlap for outer (name) labels: per side, sort by ideal y and
* push neighbours apart so they keep at least one line-height of spacing.
* Mutates each entry's `labelY`. Connector lines re-route to the moved y.
*/
placeExternalLabels() {
const w = this.w
// Minimum spacing is a full label block (n lines) so multi-line labels
// (e.g. name + percent) don't overlap their neighbours.
const lineHeight = this.externalLabelMaxLines * this.externalLabelLineH + 2
const maxY =
this.centerY + w.globals.radialSize + w.globals.pieExternalLabelMarginY
;['left', 'right'].forEach((side) => {
// No top clamp: the pie is centred in its own chart, so a column pulled
// up as a block stays on screen.
spaceOutLabels(
this.externalLabels.filter((l) => l.side === side),
lineHeight,
maxY,
)
})
}
/**
* @param {any} elPath
* @param {Record<string, any>} dataLabels
* @param {number} [i] slice index, for the hover outline band
*/
addListeners(elPath, dataLabels, i) {
const graphics = new Graphics(this.w, this.ctx)
// append filters on mouseenter and mouseleave
elPath.node.addEventListener(
'mouseenter',
graphics.pathMouseEnter.bind(graphics, elPath),
)
elPath.node.addEventListener(
'mouseleave',
graphics.pathMouseLeave.bind(graphics, elPath),
)
elPath.node.addEventListener(
'mouseleave',
this.revertDataLabelsInner.bind(this),
)
if (typeof i === 'number') {
elPath.node.addEventListener(
'mouseenter',
this.showHoverOutline.bind(this, i),
)
elPath.node.addEventListener(
'mouseleave',
this.hideHoverOutline.bind(this),
)
}
elPath.node.addEventListener(
'mousedown',
graphics.pathMouseDown.bind(graphics, elPath),
)
if (!this.donutDataLabels.total.showAlways) {
elPath.node.addEventListener(
'mouseenter',
this.printDataLabelsInner.bind(this, elPath.node, dataLabels),
)
elPath.node.addEventListener(
'mousedown',
this.printDataLabelsInner.bind(this, elPath.node, dataLabels),
)
}
}
// This function can be used for other circle charts too
/**
* @param {any} el
* @param {Record<string, any>} opts
*/
animatePaths(el, opts) {
const w = this.w
const me = this
let angle =
opts.endAngle < opts.startAngle
? this.fullAngle + opts.endAngle - opts.startAngle
: opts.endAngle - opts.startAngle
let prevAngle = angle
let fromStartAngle = opts.startAngle
const toStartAngle = opts.startAngle
if (opts.prevStartAngle !== undefined && opts.prevEndAngle !== undefined) {
fromStartAngle = opts.prevEndAngle
prevAngle =
opts.prevEndAngle < opts.prevStartAngle
? this.fullAngle + opts.prevEndAngle - opts.prevStartAngle
: opts.prevEndAngle - opts.prevStartAngle
}
if (opts.i === w.config.series.length - 1) {
// some adjustments for the last overlapping paths
if (angle + toStartAngle > this.fullAngle) {
opts.endAngle = opts.endAngle - (angle + toStartAngle)
} else if (angle + toStartAngle < this.fullAngle) {
opts.endAngle =
opts.endAngle + (this.fullAngle - (angle + toStartAngle))
}
}
if (angle === this.fullAngle) angle = this.fullAngle - 0.01
me.animateArc(el, fromStartAngle, toStartAngle, angle, prevAngle, opts)
}
/**
* @param {any} el
* @param {number} fromStartAngle
* @param {number} toStartAngle
* @param {number} angle
* @param {number} prevAngle
* @param {Record<string, any>} opts
*/
animateArc(el, fromStartAngle, toStartAngle, angle, prevAngle, opts) {
const me = this
const w = this.w
const animations = new Animations(this.w)
const size = opts.size
let path
if (isNaN(fromStartAngle) || isNaN(prevAngle)) {
fromStartAngle = toStartAngle
prevAngle = angle
opts.dur = 0
}
let currAngle = angle
let startAngle = toStartAngle
const fromAngle =
fromStartAngle < toStartAngle
? this.fullAngle + fromStartAngle - toStartAngle
: fromStartAngle - toStartAngle
// polarArea animates its value channel, the radius; pie/donut never pass
// prevSize (their radius is fixed) so this stays their exact old path.
const hasPrevSize = typeof opts.prevSize === 'number'
if (w.globals.dataChanged && opts.shouldSetPrevPaths) {
// to avoid flicker when updating, set prev path first and then animate from there
if (opts.prevEndAngle) {
path = me.getPiePath({
me,
startAngle: opts.prevStartAngle,
angle:
opts.prevEndAngle < opts.prevStartAngle
? this.fullAngle + opts.prevEndAngle - opts.prevStartAngle
: opts.prevEndAngle - opts.prevStartAngle,
size: hasPrevSize ? opts.prevSize : size,
})
el.attr({ d: path })
}
}
if (opts.dur !== 0) {
el.animate(opts.dur, opts.animBeginArr[opts.i])
.after(
/** @this {any} */ function () {
if (
me.chartType === 'pie' ||
me.chartType === 'donut' ||
me.chartType === 'polarArea'
) {
this.animate(
w.config.chart.animations.dynamicAnimation.speed,
).attr({
'stroke-width': me.strokeWidth,
})
}
if (opts.i === w.config.series.length - 1) {
animations.animationCompleted(el)
}
},
)
/**
* @param {Record<string, any>} pos
*/
.during((/** @type {any} */ pos) => {
currAngle = fromAngle + (angle - fromAngle) * pos
if (opts.animateStartingPos) {
currAngle = prevAngle + (angle - prevAngle) * pos
startAngle =
fromStartAngle -
prevAngle +
(toStartAngle - (fromStartAngle - prevAngle)) * pos
}
path = me.getPiePath({
me,
startAngle,
angle: currAngle,
size: hasPrevSize
? opts.prevSize + (size - opts.prevSize) * pos
: size,
})
el.node.setAttribute('data:pathOrig', path)
el.attr({
d: path,
})
})
} else {
path = me.getPiePath({
me,
startAngle,
angle,
size,
})
if (!opts.isTrack) {
w.globals.animationEnded = true
}
el.node.setAttribute('data:pathOrig', path)
el.attr({
d: path,
'stroke-width': me.strokeWidth,
})
}
}
/**
* Toggle slice `i` in or out of the pie. Only one slice sits outside at a
* time. Bound to `mouseup` on each slice, and also reached from the
* `toggleDataPointSelection` API and from the pre-selected-slice pass in
* drawArcs (which passes `animate: false` so the slice is already parked by
* the time the first frame is painted).
* @param {number} i
* @param {{animate?: boolean}} [opts] a MouseEvent when called as a listener,
* which carries no `animate`, so real clicks animate
*/
pieClicked(i, opts) {
const w = this.w
const me = this
const animate = !(opts && opts.animate === false)
const elPath = w.dom.Paper.findOne(
`.apexcharts-${me.chartType.toLowerCase()}-slice-${i}`,
)
if (!elPath) return
if (elPath.attr('data:pieClicked') === 'true') {
elPath.attr({
'data:pieClicked': 'false',
})
this.revertDataLabelsInner()
this.offsetSlice(i, 0, animate)
return
}
// Pull in whichever slice is currently out. Only slices actually marked out
// are touched: an unconditional pass would write a transform onto every
// slice, and (as it did when this restored `data:pathOrig` instead) fight
// with a cross-type morph still animating the paths.
const allEls = w.dom.baseEl.getElementsByClassName('apexcharts-pie-area')
/**
* @param {any} pieSlice
*/
Array.prototype.forEach.call(allEls, (pieSlice) => {
const wasOut = pieSlice.getAttribute('data:pieClicked') === 'true'
pieSlice.setAttribute('data:pieClicked', 'false')
if (wasOut) {
this.offsetSlice(parseInt(pieSlice.getAttribute('j'), 10), 0, animate)
}
})
w.interact.capturedDataPointIndex = i
elPath.attr('data:pieClicked', 'true')
this.offsetSlice(i, this.getExpandOffset(), animate)
}
/**
* How far a clicked slice slides out, in px. 0 when the pull-out is off, when
* drilldown owns the click, and always for polarArea: there the radius
* encodes the value, so moving a slice outward would read as a bigger number.
* @returns {number}
*/
getExpandOffset() {
const pie = this.w.config.plotOptions.pie
if (!pie.expandOnClick || this.chartType === 'polarArea') return 0
if (Filters.drilldownBlocksSliceOffset(this.w)) return 0
const offset = Number(pie.expandOffset)
return Number.isFinite(offset) && offset > 0 ? offset : 0
}
/**
* Every node that has to travel with slice `i`: the slice path itself plus
* its labels, which live in sibling groups so they paint above all slices.
* @param {number} i
* @returns {SVGElement[]}
*/
getSliceMovers(i) {
const elPath = this.w.dom.Paper.findOne(
`.apexcharts-${this.chartType.toLowerCase()}-slice-${i}`,
)
return [
elPath ? elPath.node : null,
this.sliceLabelGroups[i],
this.externalLabelGroups[i],
// The hover band, when it is this slice's: clicking a slice you are
// hovering has to take its outline along, or the band is left behind
// sitting in the gap the slice just opened.
this.hoverOutlineIndex === i && this.elHoverOutlinePath
? this.elHoverOutlinePath.node
: null,
].filter(Boolean)
}
/**
* Slide slice `i` `dist` px out of the pie along its own mid-angle, or back
* to its resting place when `dist` is 0.
*
* The slice is *translated*, never re-drawn: the arc keeps the exact radius
* and span it had, so the pulled-out slice still encodes the same quantity
* (growing the radius, as this used to, quietly inflates it) and a clean gap
* opens between it and the rest of the pie.
* @param {number} i
* @param {number} dist
* @param {boolean} [animate] false to park it instantly, with no transition
*/
offsetSlice(i, dist, animate = true) {
const w = this.w
const elPath = w.dom.Paper.findOne(
`.apexcharts-${this.chartType.toLowerCase()}-slice-${i}`,
)
if (!elPath) return
const { dx, dy } = this.getSliceOffsetVector(i, dist)
// translate(0 0) rather than dropping the attribute: an attribute removal
// is a computed-value jump to `none` in some engines, which skips the
// transition on the way back in.
const transform = `translate(${dx} ${dy})`
const transition =
animate && w.config.chart.animations.enabled ? SLICE_OFFSET_TRANSITION : ''
this.getSliceMovers(i).forEach((node) => {
// The `transform` presentation attribute maps onto the CSS transform
// property, so a CSS transition on it animates the slide wherever SVG
// CSS transforms are supported, and degrades to an instant move (the old
// behaviour) where they are not.
node.style.transition = transition
node.setAttribute('transform', transform)
})
}
/**
* The px vector `dist` along slice `i`'s mid-angle. Zero for a slice that
* fills the pie: there is no "outside" for it to move to, and sliding it
* would just shift the whole chart sideways.
* @param {number} i
* @param {number} dist
* @returns {{dx: number, dy: number}}
*/
getSliceOffsetVector(i, dist) {
const elPath = this.w.dom.Paper.findOne(
`.apexcharts-${this.chartType.toLowerCase()}-slice-${i}`,
)
const angle = elPath ? parseFloat(elPath.attr('data:angle')) : NaN
if (!dist || !Number.isFinite(angle) || angle >= this.fullAngle) {
return { dx: 0, dy: 0 }
}
const startAngle = parseFloat(elPath.attr('data:startAngle'))
const midRad = (Math.PI * (startAngle + angle / 2 - 90)) / 180
return { dx: dist * Math.cos(midRad), dy: dist * Math.sin(midRad) }
}
/**
* Fade in the hover outline: a translucent band traced just outside the rim
* of slice `i`, in the slice's own colour. It replaces lightening the slice
* (see Filters.hoverOutlineOwnsHoverState) so a hovered slice keeps the
* colour the legend and the data labels claim it has.
* @param {number} i
*/
showHoverOutline(i) {
const w = this.w
if (!Filters.hoverOutlineOwnsHoverState(w)) return
if (!this.elHoverOutline) return
const path = this.getHoverOutlinePath(i)
if (!path) return
const cfg = w.config.plotOptions.pie.hoverOutline
const fill = cfg.color || w.globals.colors[i]
// One band node, re-plotted per slice: sweeping across a pie then costs no
// DOM churn, and the fade below stays a plain opacity transition.
if (!this.elHoverOutlinePath) {
const graphics = new Graphics(w)
this.elHoverOutlinePath = graphics.drawPath({
d: path,
fill,
strokeWidth: 0,
classes: 'apexcharts-pie-hover-outline-band',
})
this.elHoverOutline.add(this.elHoverOutlinePath)
}
this.elHoverOutlinePath.attr({
d: path,
fill,
'fill-opacity': cfg.opacity,
})
// The band belongs to the slice, so it has to sit at whatever offset that
// slice is currently parked at (see offsetSlice). The offset rides on the
// band path and the fade on the group above it, which keeps the two
// independent: the band jumps to a newly hovered slice (no transition here)
// but slides when the slice it is already on is clicked out.
const { dx, dy } = this.getSliceOffsetVector(
i,
this.isSliceOut(i) ? this.getExpandOffset() : 0,
)
this.elHoverOutlinePath.node.style.transition = ''
this.elHoverOutlinePath.node.setAttribute('transform', `translate(${dx} ${dy})`)
this.hoverOutlineIndex = i
this.elHoverOutline.node.style.opacity = '1'
}
/** Fade the hover outline back out, leaving the band node in place. */
hideHoverOutline() {
if (this.elHoverOutline) {
this.elHoverOutline.node.style.opacity = '0'
}
}
/** @param {number} i @returns {boolean} */
isSliceOut(i) {
const elPath = this.w.dom.Paper.findOne(
`.apexcharts-${this.chartType.toLowerCase()}-slice-${i}`,
)
return !!elPath && elPath.attr('data:pieClicked') === 'true'
}
/**
* Band geometry for the hover outline of slice `i`: an annulus from the
* slice rim (plus the stroke and the configured clearance) outward, over the
* same angular extent the slice is actually drawn over, so it lines up with
* both slice edges even with `spacing` insetting them. Rounded into a pill
* when the band is thick enough for the fillets to fit.
* @param {number} i
* @returns {string | null}
*/
getHoverOutlinePath(i) {
const w = this.w
const cfg = w.config.plotOptions.pie.hoverOutline
const elPath = w.dom.Paper.findOne(
`.apexcharts-${this.chartType.toLowerCase()}-slice-${i}`,
)
if (!elPath) return null
const angle = parseFloat(elPath.attr('data:angle'))
if (!Number.isFinite(angle) || angle <= 0) return null
const size = this.sliceSizes[i]
const thickness = Number(cfg.size)
if (!Number.isFinite(size) || !Number.isFinite(thickness) || thickness <= 0) {
return null
}
const { startDeg, spanDeg } = this.getSliceExtent({
me: this,
startAngle: parseFloat(elPath.attr('data:startAngle')),
angle,
size,
})
if (!(spanDeg > 0)) return null
// Half the stroke, not all of it: the slice stroke is centred on the rim,
// so only half of it sits outside. Adding the full width pushed the band a
// stroke-width further out than the configured gap asked for.
const rIn = size + (this.strokeWidth || 0) / 2 + (Number(cfg.gap) || 0)
const rOut = rIn + thickness
const geo = {
cx: this.centerX,
cy: this.centerY,
rIn,
rOut,
a0: startDeg,
a1: startDeg + spanDeg,
spanDeg,
}
// Pill ends read as deliberate; square ends on a thin band read as a
// clipping artifact. Bounded by the inner arc length so the two fillets
// never cross on a narrow slice.
const r = Math.min(thickness / 2, ((spanDeg * Math.PI) / 180 / 2) * rIn)
return r > 0.5
? roundedDonutSegmentPath({ ...geo, r })
: sharpDonutSegmentPath(geo)
}
/**
* @param {number} prevStartAngle
* @param {number} prevEndAngle
*/
/**
* @param {number} prevStartAngle
* @param {number} prevEndAngle
* @param {number} [prevSize] - polarArea passes its previous slice radius,
* so the pre-animation frame sits at the previous VALUE too, not just the
* previous angles.
*/
getChangedPath(prevStartAngle, prevEndAngle, prevSize) {
let path = ''
if (this.dynamicAnim && this.w.globals.dataChanged) {
path = this.getPiePath({
me: this,
startAngle: prevStartAngle,
angle: prevEndAngle - prevStartAngle,
// @ts-ignore — size is set dynamically during draw()
size: typeof prevSize === 'number' ? prevSize : this.size,
})
}
return path
}
/**
* The angular extent a slice is actually drawn over: the raw start / span
* clamped so a full circle never overlaps itself, then inset by
* `plotOptions.pie.spacing`. Shared by getPiePath and the hover outline, so
* the band lines up with the slice edges instead of with the un-inset angles
* cached on the path node.
* @param {{me: any, startAngle: number, angle: number, size: number}} opts
* @returns {{startDeg: number, spanDeg: number, endDeg: number}}
*/
getSliceExtent({ me, startAngle, angle, size }) {
const w = this.w
let startDeg = startAngle
let endDeg = angle + startAngle
// prevent overlap
if (
Math.ceil(endDeg) >=
this.fullAngle +
(this.w.config.plotOptions.pie.startAngle % this.fullAngle)
) {
endDeg =
this.fullAngle +
(this.w.config.plotOptions.pie.startAngle % this.fullAngle) -
0.01
}
// Clamped angular span (magnitude, in degrees) before wrapping endDeg into
// [0, fullAngle]. Both builders below derive their geometry from this.
let spanDeg = endDeg - startDeg
const isSliceType =
me.chartType === 'pie' ||
me.chartType === 'donut' ||
me.chartType === 'polarArea'
// Inter-slice spacing (plotOptions.pie.spacing, in px) for pie / donut /
// polarArea. Inset both edges symmetrically so the slice mid-angle (and
// therefore its data label and hit region) is preserved. The px gap is
// measured at the mid-ring radius for donut, or the outer radius otherwise.
const spacing = w.config.plotOptions.pie.spacing
if (isSliceType && spacing > 0 && spanDeg > 0) {
const rRef = me.chartType === 'donut' ? (size + me.donutSize) / 2 : size
const gapDeg = rRef > 0 ? (spacing / rRef) * (180 / Math.PI) : 0
// never collapse the slice: keep at least a ~1deg sliver
const inset = Math.min(gapDeg / 2, Math.max(0, spanDeg / 2 - 0.5))
startDeg += inset
spanDeg -= 2 * inset
}
// Recompute endDeg from the (possibly inset) start/span, then wrap into
// [0, fullAngle] for the point math below.
endDeg = startDeg + spanDeg
if (Math.ceil(endDeg) > this.fullAngle) endDeg -= this.fullAngle
return { startDeg, spanDeg, endDeg }
}
/** @param {{me: any, startAngle: any, angle: any, size: any}} opts */
getPiePath({ me, startAngle, angle, size }) {
let path
const w = this.w
const graphics = new Graphics(this.w)
const { startDeg, spanDeg, endDeg } = this.getSliceExtent({
me,
startAngle,
angle,
size,
})
const isSliceType =
me.chartType === 'pie' ||
me.chartType === 'donut' ||
me.chartType === 'polarArea'
const startRadians = (Math.PI * (startDeg - 90)) / 180
// Rounded slice corners (plotOptions.pie.borderRadius). Returns null when
// the slice is too small to round, in which case we fall through to the
// regular sharp-corner path below.
const borderRadius = w.config.plotOptions.pie.borderRadius
if (borderRadius > 0 && isSliceType) {
const roundedPath = this.getRoundedSlicePath({
me,
startDeg,
spanDeg,
size,
borderRadius,
})
if (roundedPath) return roundedPath
}
const endRadians = (Math.PI * (endDeg - 90)) / 180
const x1 = me.centerX + size * Math.cos(startRadians)
const y1 = me.centerY + size * Math.sin(startRadians)
const x2 = me.centerX + size * Math.cos(endRadians)
const y2 = me.centerY + size * Math.sin(endRadians)
const startInner = Utils.polarToCartesian(
me.centerX,
me.centerY,
me.donutSize,
endDeg,
)
const endInner = Utils.polarToCartesian(
me.centerX,
me.centerY,
me.donutSize,
startDeg,
)
const largeArc = spanDeg > 180 ? 1 : 0
const pathBeginning = ['M', x1, y1, 'A', size, size, 0, largeArc, 1, x2, y2]
if (me.chartType === 'donut') {
path = [
...pathBeginning,
'L',
startInner.x,
startInner.y,
'A',
me.donutSize,
me.donutSize,
0,
largeArc,
0,
endInner.x,
endInner.y,
'L',
x1,
y1,
'z',
].join(' ')
} else if (me.chartType === 'pie' || me.chartType === 'polarArea') {
path = [...pathBeginning, 'L', me.centerX, me.centerY, 'L', x1, y1].join(
' ',
)
} else {
path = [...pathBeginning].join(' ')
}
return graphics.roundPathCorners(path, this.strokeWidth * 2)
}
/**
* Build a slice path with rounded corners (plotOptions.pie.borderRadius).
*
* The generic roundPathCorners() only rounds line->line joins, but a slice
* corner is an arc<->line join, so we construct the fillets explicitly here:
* every corner is inset by the (clamped) radius along both the arc and the
* radial edge, and a quadratic Bezier with its control point at the original
* sharp corner bridges the two inset points. Donut slices round all four
* corners; pie / polarArea slices round the two outer corners and keep the
* center apex sharp.
*
* Returns null when the slice is too small to round meaningfully, so the
* caller can fall back to a sharp-corner path.
*
* @param {{me: any, startDeg: number, spanDeg: number, size: number, borderRadius: number}} opts
* @returns {string | null}
*/
getRoundedSlicePath({ me, startDeg, spanDeg, size, borderRadius }) {
if (!(spanDeg > 0)) return null
const D2R = Math.PI / 180
const cx = me.centerX
const cy = me.centerY
const isDonut = me.chartType === 'donut'
const rOut = size
const rIn = isDonut ? me.donutSize : 0
const spanRad = spanDeg * D2R
// Clamp the radius so opposing corner fillets never cross: bounded by the
// room available along each arc and across the radial thickness.
let r = borderRadius
r = Math.min(r, (spanRad * rOut) / 2) // outer arc room
if (isDonut) {
r = Math.min(r, (spanRad * rIn) / 2) // inner arc room (tighter)
r = Math.min(r, (rOut - rIn) / 2) // radial thickness
} else {
r = Math.min(r, rOut / 2)
}
if (!(r > 0.5)) return null
const a0 = startDeg
const a1 = startDeg + spanDeg
if (isDonut) {
return roundedDonutSegmentPath({ cx, cy, rIn, rOut, a0, a1, r, spanDeg })
}
// pie / polarArea: round the two outer corners, keep the center apex sharp
return roundedPieSegmentPath({ cx, cy, rOut, a0, a1, r, spanDeg })
}
/**
* @param {any} parent
*/
drawPolarElements(parent) {
const w = this.w
const scale = new Scales(this.w)
const graphics = new Graphics(this.w)
const helpers = new Helpers(this.w)
const gCircles = graphics.group()
const gYAxis = graphics.group()
const yScale = scale.niceScale(0, Math.ceil(this.maxY), 0)
const yTexts = yScale.result.reverse()
const len = yScale.result.length
this.maxY = yScale.niceMax
let circleSize = w.globals.radialSize
const diff = circleSize / (len - 1)
for (let i = 0; i < len - 1; i++) {
const circle = graphics.drawCircle(circleSize)
circle.attr({
cx: this.centerX,
cy: this.centerY,
fill: 'none',
'stroke-width': w.config.plotOptions.polarArea.rings.strokeWidth,
stroke: w.config.plotOptions.polarArea.rings.strokeColor,
})
if (w.config.yaxis[0].show) {
const yLabel = helpers.drawYAxisTexts(
this.centerX,
this.centerY -
circleSize +
parseInt(w.config.yaxis[0].labels.style.fontSize, 10) / 2,
i,
yTexts[i],
)
gYAxis.add(yLabel)
}
gCircles.add(circle)
circleSize = circleSize - diff
}
this.drawSpokes(parent)
parent.add(gCircles)
parent.add(gYAxis)
}
/**
* @param {any} dataLabelsGroup
* @param {Record<string, any>} dataLabelsConfig
* @param {Record<string, any>} opts
*/
renderInnerDataLabels(dataLabelsGroup, dataLabelsConfig, opts) {
const w = this.w
const graphics = new Graphics(this.w)
const showTotal = dataLabelsConfig.total.show
dataLabelsGroup.node.innerHTML = ''
dataLabelsGroup.node.style.opacity = opts.opacity
const x = opts.centerX
const y = !this.donutDataLabels.total.label
? opts.centerY - opts.centerY / 6
: opts.centerY
let labelColor, valueColor
if (dataLabelsConfig.name.color === undefined) {
labelColor = w.globals.colors[0]
} else {
labelColor = dataLabelsConfig.name.color
}
let labelFontSize = dataLabelsConfig.name.fontSize
let labelFontFamily = dataLabelsConfig.name.fontFamily
let labelFontWeight = dataLabelsConfig.name.fontWeight
if (dataLabelsConfig.value.color === undefined) {
valueColor = w.config.chart.foreColor
} else {
valueColor = dataLabelsConfig.value.color
}
const lbFormatter = dataLabelsConfig.value.formatter
let val = ''
let name = ''
if (showTotal) {
labelColor = dataLabelsConfig.total.color
labelFontSize = dataLabelsConfig.total.fontSize
labelFontFamily = dataLabelsConfig.total.fontFamily
labelFontWeight = dataLabelsConfig.total.fontWeight
name = !this.donutDataLabels.total.label
? ''
: dataLabelsConfig.total.label
val = dataLabelsConfig.total.formatter(w)
} else {
if (w.seriesData.series.length === 1) {
val = lbFormatter(w.seriesData.series[0], w)
name = w.seriesData.seriesNames[0]
}
}
if (name) {
name = dataLabelsConfig.name.formatter(
name,
dataLabelsConfig.total.show,
w,
)
}
if (dataLabelsConfig.name.show) {
const elLabel = graphics.drawText({
x,
y: y + parseFloat(dataLabelsConfig.name.offsetY),
text: name,
textAnchor: 'middle',
foreColor: labelColor,
fontSize: labelFontSize,
fontWeight: labelFontWeight,
fontFamily: labelFontFamily,
})
elLabel.node.classList.add('apexcharts-datalabel-label')
dataLabelsGroup.add(elLabel)
}
if (dataLabelsConfig.value.show) {
const valOffset = dataLabelsConfig.name.show
? parseFloat(dataLabelsConfig.value.offsetY) + 16
: dataLabelsConfig.value.offsetY
const elValue = graphics.drawText({
x,
y: y + valOffset,
text: val,
textAnchor: 'middle',
foreColor: valueColor,
fontWeight: dataLabelsConfig.value.fontWeight,
fontSize: dataLabelsConfig.value.fontSize,
fontFamily: dataLabelsConfig.value.fontFamily,
})
elValue.node.classList.add('apexcharts-datalabel-value')
dataLabelsGroup.add(elValue)
}
// for a multi-series circle chart, we need to show total value instead of first series labels
return dataLabelsGroup
}
/**
*
* @param {string} name - The name of the series
* @param {string} val - The value of that series
* @param {any} el - Optional el (indicates which series was hovered/clicked). If this param is not present, means we need to show total
* @param {Record<string, any>} labelsConfig
*/
printInnerLabels(labelsConfig, name, val, el) {
const w = this.w
let labelColor
if (el) {
if (labelsConfig.name.color === undefined) {
labelColor =
w.globals.colors[parseInt(el.parentNode.getAttribute('rel'), 10) - 1]
} else {
labelColor = labelsConfig.name.color
}
} else {
if (w.seriesData.series.length > 1 && labelsConfig.total.show) {
labelColor = labelsConfig.total.color
}
}
const elLabel = w.dom.baseEl.querySelector('.apexcharts-datalabel-label')
const elValue = w.dom.baseEl.querySelector('.apexcharts-datalabel-value')
const lbFormatter = labelsConfig.value.formatter
val = lbFormatter(val, w)
// we need to show Total Val - so get the formatter of it
if (!el && typeof labelsConfig.total.formatter === 'function') {
val = labelsConfig.total.formatter(w)
}
const isTotal = name === labelsConfig.total.label
name = !this.donutDataLabels.total.label
? ''
: labelsConfig.name.formatter(name, isTotal, w)
if (elLabel !== null) {
elLabel.textContent = name
}
if (elValue !== null) {
elValue.textContent = val
}
if (elLabel !== null) {
const elLabelEl = /** @type {HTMLElement} */ (elLabel)
elLabelEl.style.fill = labelColor
}
}
/**
* @param {any} el
* @param {Record<string, any>} dataLabelsConfig
*/
printDataLabelsInner(el, dataLabelsConfig) {
const w = this.w
const val = el.getAttribute('data:value')
const name =
w.seriesData.seriesNames[
parseInt(el.parentNode.getAttribute('rel'), 10) - 1
]
if (w.seriesData.series.length > 1) {
this.printInnerLabels(dataLabelsConfig, name, val, el)
}
const dataLabelsGroup = w.dom.baseEl.querySelector(
'.apexcharts-datalabels-group',
)
if (dataLabelsGroup !== null) {
const dataLabelsGroupEl = /** @type {HTMLElement} */ (dataLabelsGroup)
dataLabelsGroupEl.style.opacity = '1'
}
}
/**
* @param {any} parent
*/
drawSpokes(parent) {
const w = this.w
const graphics = new Graphics(this.w)
const spokeConfig = w.config.plotOptions.polarArea.spokes
if (spokeConfig.strokeWidth === 0) return
const spokes = []
const angleDivision = 360 / w.seriesData.series.length
for (let i = 0; i < w.seriesData.series.length; i++) {
spokes.push(
Utils.polarToCartesian(
this.centerX,
this.centerY,
w.globals.radialSize,
w.config.plotOptions.pie.startAngle + angleDivision * i,
),
)
}
spokes.forEach((p, i) => {
const line = graphics.drawLine(
p.x,
p.y,
this.centerX,
this.centerY,
Array.isArray(spokeConfig.connectorColors)
? spokeConfig.connectorColors[i]
: spokeConfig.connectorColors,
)
parent.add(line)
})
}
revertDataLabelsInner() {
const w = this.w
if (this.donutDataLabels.show) {
const dataLabelsGroup = w.dom.Paper.findOne(
`.apexcharts-datalabels-group`,
)
const dataLabels = this.renderInnerDataLabels(
dataLabelsGroup,
this.donutDataLabels,
{
hollowSize: this.donutSize,
centerX: this.centerX,
centerY: this.centerY,
opacity: this.donutDataLabels.show,
},
)
const elPie = w.dom.Paper.findOne(
'.apexcharts-radialbar, .apexcharts-pie',
)
elPie.add(dataLabels)
}
}
}
export default Pie