apexcharts
Version:
A JavaScript Chart Library
992 lines (855 loc) • 30 kB
JavaScript
// @ts-check
import Pie from './Pie'
import Utils from '../utils/Utils'
import Fill from '../modules/Fill'
import Graphics from '../modules/Graphics'
import Filters from '../modules/Filters'
import Series from '../modules/Series'
import { BrowserAPIs } from '../ssr/BrowserAPIs'
import { Environment } from '../utils/Environment'
/**
* ApexCharts Radial Class for drawing Circle / Semi Circle Charts.
* @module Radial
**/
class Radial extends Pie {
/**
* @param {import('../types/internal').ChartStateW} w
* @param {import('../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
super(w, ctx)
this.ctx = ctx
this.w = w
this.animBeginArr = [0]
this.animDur = 0
this.startAngle = w.config.plotOptions.radialBar.startAngle
this.endAngle = w.config.plotOptions.radialBar.endAngle
this.totalAngle = Math.abs(
w.config.plotOptions.radialBar.endAngle -
w.config.plotOptions.radialBar.startAngle,
)
this.trackStartAngle = w.config.plotOptions.radialBar.track.startAngle
this.trackEndAngle = w.config.plotOptions.radialBar.track.endAngle
this.barLabels = this.w.config.plotOptions.radialBar.barLabels
this.donutDataLabels = this.w.config.plotOptions.radialBar.dataLabels
this.radialDataLabels = this.donutDataLabels // make a copy for easy reference
if (!this.trackStartAngle) this.trackStartAngle = this.startAngle
if (!this.trackEndAngle) this.trackEndAngle = this.endAngle
if (this.endAngle === 360) this.endAngle = 359.99
this.margin = parseInt(w.config.plotOptions.radialBar.track.margin, 10)
this.onBarLabelClick = this.onBarLabelClick.bind(this)
}
/**
* @param {any[]} series
*/
draw(series) {
const w = this.w
const graphics = new Graphics(this.w)
const ret = graphics.group({
class: 'apexcharts-radialbar',
})
if (w.globals.noData) return ret
const elSeries = graphics.group()
const centerY = this.defaultSize / 2
const centerX = w.layout.gridWidth / 2
let size = this.defaultSize / 2.05
if (!w.config.chart.sparkline.enabled) {
size = size - w.config.stroke.width - w.config.chart.dropShadow.blur
}
const colorArr = w.globals.fill.colors
const rb = w.config.plotOptions.radialBar
const hasBands = Array.isArray(rb.bands) && rb.bands.length > 0
const hideTrack =
hasBands && rb.bandsStyle && rb.bandsStyle.hideTrackWhenPresent
const isNeedleShape = rb.shape === 'needle'
if (rb.track.show && !hideTrack) {
const elTracks = this.drawTracks({
size,
centerX,
centerY,
colorArr,
series,
})
elSeries.add(elTracks)
}
if (hasBands) {
const elBands = this.drawBands({
size,
centerX,
centerY,
series,
})
elSeries.add(elBands)
}
const elG = this.drawArcs({
size,
centerX,
centerY,
colorArr,
series,
// When `needle.showValueArc` is true, render both the filled value-arc
// and the needle on top — required for gauges that want a progress
// ring with a pointer indicator (default still hides the arc when in
// needle shape, preserving prior behavior).
skipValueArc: isNeedleShape && !rb.needle?.showValueArc,
})
if (rb.ticks && rb.ticks.show) {
const elTicks = this.drawTicks({
size,
centerX,
centerY,
series,
})
// On initial mount, ticks/labels fade in after the value arc + needle
// finish their sweep — so they read as a "settled" annotation rather
// than competing for attention during the gauge animation.
const isInitialMount =
this.initialAnim && !w.globals.dataChanged && !w.globals.resized
if (isInitialMount && Environment.isBrowser() && w.globals.shouldAnimate) {
const ticksNode = elTicks.node
ticksNode.style.opacity = '0'
ticksNode.style.transition = 'opacity 280ms ease-out'
const sweepDur = w.config.chart.animations.speed || 800
setTimeout(() => {
ticksNode.style.opacity = '1'
}, sweepDur)
}
elSeries.add(elTicks)
}
if (isNeedleShape) {
const elNeedle = this.drawNeedle({
size,
centerX,
centerY,
series,
})
elSeries.add(elNeedle)
}
let totalAngle = 360
if (w.config.plotOptions.radialBar.startAngle < 0) {
totalAngle = this.totalAngle
}
const angleRatio = (360 - totalAngle) / 360
w.globals.radialSize = size - size * angleRatio
if (this.radialDataLabels.value.show) {
const offset = Math.max(
this.radialDataLabels.value.offsetY,
this.radialDataLabels.name.offsetY,
)
w.globals.radialSize += offset * angleRatio
}
elSeries.add(elG.g)
if (w.config.plotOptions.radialBar.hollow.position === 'front') {
elG.g.add(elG.elHollow)
if (elG.dataLabels) {
elG.g.add(elG.dataLabels)
}
}
ret.add(elSeries)
return ret
}
/**
* @param {Record<string, any>} opts
*/
drawTracks(opts) {
const w = this.w
const graphics = new Graphics(this.w)
const g = graphics.group({
class: 'apexcharts-tracks',
})
const filters = new Filters(this.w)
const fill = new Fill(this.w)
const strokeWidth = this.getStrokeWidth(opts)
opts.size = opts.size - strokeWidth / 2
for (let i = 0; i < opts.series.length; i++) {
const elRadialBarTrack = graphics.group({
class: 'apexcharts-radialbar-track apexcharts-track',
})
g.add(elRadialBarTrack)
elRadialBarTrack.attr({
rel: i + 1,
})
opts.size = opts.size - strokeWidth - this.margin
const trackConfig = w.config.plotOptions.radialBar.track
const pathFill = fill.fillPath({
seriesNumber: 0,
size: opts.size,
fillColors: Array.isArray(trackConfig.background)
? trackConfig.background[i]
: trackConfig.background,
solid: true,
})
const startAngle = this.trackStartAngle
let endAngle = this.trackEndAngle
if (Math.abs(endAngle) + Math.abs(startAngle) >= 360)
endAngle = 360 - Math.abs(this.startAngle) - 0.1
const elPath = graphics.drawPath({
d: '',
stroke: pathFill,
strokeWidth:
(strokeWidth * parseInt(trackConfig.strokeWidth, 10)) / 100,
fill: 'none',
strokeOpacity: trackConfig.opacity,
classes: 'apexcharts-radialbar-area',
})
if (trackConfig.dropShadow.enabled) {
const shadow = trackConfig.dropShadow
filters.dropShadow(elPath, shadow)
}
elRadialBarTrack.add(elPath)
elPath.attr('id', 'apexcharts-radialbarTrack-' + i)
this.animatePaths(elPath, {
centerX: opts.centerX,
centerY: opts.centerY,
endAngle,
startAngle,
size: opts.size,
i,
totalItems: 2,
animBeginArr: 0,
dur: 0,
isTrack: true,
})
}
return g
}
/**
* @param {Record<string, any>} opts
*/
drawArcs(opts) {
const w = this.w
// size, donutSize, centerX, centerY, colorArr, lineColorArr, sectorAngleArr, series
const graphics = new Graphics(this.w)
const fill = new Fill(this.w)
const filters = new Filters(this.w)
const g = graphics.group()
const strokeWidth = this.getStrokeWidth(opts)
opts.size = opts.size - strokeWidth / 2
let hollowFillID = w.config.plotOptions.radialBar.hollow.background
const hollowSize =
opts.size -
strokeWidth * opts.series.length -
this.margin * opts.series.length -
(strokeWidth *
parseInt(w.config.plotOptions.radialBar.track.strokeWidth, 10)) /
100 /
2
const hollowRadius =
hollowSize - w.config.plotOptions.radialBar.hollow.margin
if (w.config.plotOptions.radialBar.hollow.image !== undefined) {
hollowFillID = this.drawHollowImage(opts, g, hollowSize, hollowFillID)
}
const elHollow = this.drawHollow({
size: hollowRadius,
centerX: opts.centerX,
centerY: opts.centerY,
fill: hollowFillID ? hollowFillID : 'transparent',
})
if (w.config.plotOptions.radialBar.hollow.dropShadow.enabled) {
const shadow = w.config.plotOptions.radialBar.hollow.dropShadow
filters.dropShadow(elHollow, shadow)
}
let shown = 1
if (!this.radialDataLabels.total.show && w.seriesData.series.length > 1) {
shown = 0
}
let dataLabels = null
if (this.radialDataLabels.show) {
const dataLabelsGroup = w.dom.Paper.findOne(
`.apexcharts-datalabels-group`,
)
dataLabels = this.renderInnerDataLabels(
dataLabelsGroup,
this.radialDataLabels,
{
hollowSize,
centerX: opts.centerX,
centerY: opts.centerY,
opacity: shown,
},
)
}
if (w.config.plotOptions.radialBar.hollow.position === 'back') {
g.add(elHollow)
if (dataLabels) {
g.add(dataLabels)
}
}
let reverseLoop = false
if (w.config.plotOptions.radialBar.inverseOrder) {
reverseLoop = true
}
for (
let i = reverseLoop ? opts.series.length - 1 : 0;
reverseLoop ? i >= 0 : i < opts.series.length;
reverseLoop ? i-- : i++
) {
const elRadialBarArc = graphics.group({
class: `apexcharts-series apexcharts-radial-series`,
seriesName: Utils.escapeString(w.seriesData.seriesNames[i]),
})
g.add(elRadialBarArc)
elRadialBarArc.attr({
rel: i + 1,
'data:realIndex': i,
})
Series.addCollapsedClassToSeries(this.w, elRadialBarArc, i)
opts.size = opts.size - strokeWidth - this.margin
const pathFill = fill.fillPath({
seriesNumber: i,
size: opts.size,
value: opts.series[i],
})
const startAngle = this.startAngle
let prevStartAngle
// Map raw value → [0, 1] fraction of the sweep, using the configured
// min/max domain. Defaults (min: 0, max: 100) preserve the historical
// percentage behavior; custom domains (e.g. min: 0, max: 240 for a
// speedometer) make the filled arc honor the same domain as the
// needle, ticks, and threshold bands.
const rb = w.config.plotOptions.radialBar
const domainMin = typeof rb.min === 'number' ? rb.min : 0
const domainMax = typeof rb.max === 'number' ? rb.max : 100
const domainSpan = domainMax === domainMin ? 1 : domainMax - domainMin
/** @param {number} v */
const valueToFraction = (v) => {
const clamped = Math.min(Math.max(v, domainMin), domainMax)
return Math.max(0, (clamped - domainMin) / domainSpan)
}
const dataValue = valueToFraction(Utils.negToZero(opts.series[i]))
let endAngle = Math.round(this.totalAngle * dataValue) + this.startAngle
let prevEndAngle
if (w.globals.dataChanged) {
prevStartAngle = this.startAngle
prevEndAngle =
Math.round(
this.totalAngle *
valueToFraction(Utils.negToZero(w.globals.previousPaths[i])),
) + prevStartAngle
}
const currFullAngle = Math.abs(endAngle) + Math.abs(startAngle)
if (currFullAngle > 360) {
endAngle = endAngle - 0.01
}
const prevFullAngle = Math.abs(prevEndAngle) + Math.abs(prevStartAngle)
if (prevFullAngle > 360) {
prevEndAngle = prevEndAngle - 0.01
}
const angle = endAngle - startAngle
const dashArray = Array.isArray(w.config.stroke.dashArray)
? w.config.stroke.dashArray[i]
: w.config.stroke.dashArray
const elPath = graphics.drawPath({
d: '',
stroke: opts.skipValueArc ? 'transparent' : pathFill,
strokeWidth: opts.skipValueArc ? 0 : strokeWidth,
fill: 'none',
fillOpacity: w.config.fill.opacity,
classes: 'apexcharts-radialbar-area apexcharts-radialbar-slice-' + i,
strokeDashArray: dashArray,
})
const radialMidAngle = startAngle + angle / 2
const radialArcCenter = Utils.polarToCartesian(
opts.centerX,
opts.centerY,
opts.size,
radialMidAngle,
)
Graphics.setAttrs(elPath.node, {
'data:angle': angle,
'data:value': opts.series[i],
'data:cx': radialArcCenter.x,
'data:cy': radialArcCenter.y,
})
if (w.config.chart.dropShadow.enabled) {
const shadow = w.config.chart.dropShadow
filters.dropShadow(elPath, shadow, i)
}
filters.setSelectionFilter(elPath, 0, i)
this.addListeners(elPath, this.radialDataLabels)
elRadialBarArc.add(elPath)
elPath.attr({
index: 0,
j: i,
})
if (this.barLabels.enabled) {
const barStartCords = Utils.polarToCartesian(
opts.centerX,
opts.centerY,
opts.size,
startAngle,
)
const text = this.barLabels.formatter(w.seriesData.seriesNames[i], {
seriesIndex: i,
w,
})
const classes = ['apexcharts-radialbar-label']
if (!this.barLabels.onClick) {
classes.push('apexcharts-no-click')
}
let textColor = this.barLabels.useSeriesColors
? w.globals.colors[i]
: w.config.chart.foreColor
if (!textColor) {
textColor = w.config.chart.foreColor
}
const x = barStartCords.x + this.barLabels.offsetX
const y = barStartCords.y + this.barLabels.offsetY
const elText = graphics.drawText({
x,
y,
text,
textAnchor: 'end',
dominantBaseline: 'middle',
fontFamily: this.barLabels.fontFamily,
fontWeight: this.barLabels.fontWeight,
fontSize: this.barLabels.fontSize,
foreColor: textColor,
cssClass: classes.join(' '),
})
elText.on('click', this.onBarLabelClick)
elText.attr({
rel: i + 1,
})
if (startAngle !== 0) {
elText.attr({
'transform-origin': `${x} ${y}`,
transform: `rotate(${startAngle} 0 0)`,
})
}
elRadialBarArc.add(elText)
}
let dur = 0
if (this.initialAnim && !w.globals.resized && !w.globals.dataChanged) {
dur = w.config.chart.animations.speed
}
if (w.globals.dataChanged) {
dur = w.config.chart.animations.dynamicAnimation.speed
}
this.animDur = dur / (opts.series.length * 1.2) + this.animDur
this.animBeginArr.push(this.animDur)
this.animatePaths(elPath, {
centerX: opts.centerX,
centerY: opts.centerY,
endAngle,
startAngle,
prevEndAngle,
prevStartAngle,
size: opts.size,
i,
totalItems: 2,
animBeginArr: this.animBeginArr,
dur,
shouldSetPrevPaths: true,
})
}
return {
g,
elHollow,
dataLabels,
}
}
/**
* Map a domain value (between `min` and `max`) to the corresponding angle
* in the gauge's `startAngle`..`endAngle` range. Values outside the
* domain are clamped.
*
* @param {number} value
* @returns {number}
*/
_angleAtValue(value) {
const rb = this.w.config.plotOptions.radialBar
const min = typeof rb.min === 'number' ? rb.min : 0
const max = typeof rb.max === 'number' ? rb.max : 100
const safeMax = max === min ? min + 1 : max
const clamped = Math.max(min, Math.min(safeMax, Number(value)))
const t = (clamped - min) / (safeMax - min)
return this.startAngle + t * (this.endAngle - this.startAngle)
}
/**
* Build an SVG arc path from `startAngle` to `endAngle` at radius `r`
* around `(cx, cy)`. Angles are in degrees, with 0° at the top.
* Used by drawBands; mirrors the `M ... A ... ` form used elsewhere.
*
* @param {number} cx
* @param {number} cy
* @param {number} r
* @param {number} startAngle
* @param {number} endAngle
* @returns {string}
*/
_describeArc(cx, cy, r, startAngle, endAngle) {
const start = Utils.polarToCartesian(cx, cy, r, endAngle)
const end = Utils.polarToCartesian(cx, cy, r, startAngle)
const sweep = endAngle - startAngle
const largeArc = Math.abs(sweep) > 180 ? 1 : 0
// sweepFlag: 0 = anti-clockwise from `end` to `start`, which together with
// polarToCartesian's orientation produces a clockwise visual arc.
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 0 ${end.x} ${end.y}`
}
/**
* Draw threshold bands as colored arc segments along the gauge arc.
* Bands sit behind the value-arc and tick marks. Used for gauges that
* indicate ranges like 0-30 critical / 30-70 warning / 70-100 healthy.
*
* @param {Record<string, any>} opts
*/
drawBands(opts) {
const w = this.w
const graphics = new Graphics(this.w)
const rb = w.config.plotOptions.radialBar
const bands = rb.bands || []
const g = graphics.group({
class: 'apexcharts-gauge-bands',
})
// Mirror the size calculation drawArcs would apply for the first series
// so bands sit on the same arc radius as the value arc.
const strokeWidth = this.getStrokeWidth(opts)
const radius = opts.size - strokeWidth / 2 - strokeWidth - this.margin
const bandStroke =
(strokeWidth * parseInt(rb.bandsStyle.strokeWidth, 10)) / 100
const min = typeof rb.min === 'number' ? rb.min : 0
const max = typeof rb.max === 'number' ? rb.max : 100
const gapDeg =
max === min
? 0
: (rb.bandsStyle.gap || 0) *
((this.endAngle - this.startAngle) / (max - min))
for (let b = 0; b < bands.length; b++) {
const band = bands[b]
if (band.from === undefined || band.to === undefined) continue
const a1 = this._angleAtValue(band.from)
const a2 = this._angleAtValue(band.to)
const startA = Math.min(a1, a2) + gapDeg / 2
const endA = Math.max(a1, a2) - gapDeg / 2
if (endA - startA <= 0) continue
const elBand = graphics.drawPath({
d: this._describeArc(opts.centerX, opts.centerY, radius, startA, endA),
stroke: band.color || '#ccc',
strokeWidth: bandStroke,
fill: 'none',
strokeLinecap: rb.bandsStyle.linecap || 'butt',
classes: 'apexcharts-gauge-band',
})
elBand.node.setAttribute('data-band-index', String(b))
g.add(elBand)
}
return g
}
/**
* Draw tick marks (major + minor) along the gauge arc, with optional
* value labels at each major tick.
*
* @param {Record<string, any>} opts
*/
drawTicks(opts) {
const w = this.w
const graphics = new Graphics(this.w)
const rb = w.config.plotOptions.radialBar
const ticks = rb.ticks
const g = graphics.group({ class: 'apexcharts-gauge-ticks' })
const strokeWidth = this.getStrokeWidth(opts)
const radius = opts.size - strokeWidth / 2 - strokeWidth - this.margin
const min = typeof rb.min === 'number' ? rb.min : 0
const max = typeof rb.max === 'number' ? rb.max : 100
const majorCount = Math.max(2, ticks.major?.count ?? 11)
const minorCount = Math.max(0, ticks.minor?.count ?? 0)
/**
* @param {number} value
* @param {Record<string, any>} cfg
* @param {boolean} isMajor
*/
const drawTickAt = (value, cfg, isMajor) => {
const angle = this._angleAtValue(value)
const length = cfg.length ?? 8
const inner =
cfg.placement === 'inside' ? radius - length : radius
const outer =
cfg.placement === 'inside' ? radius : radius + length
const p1 = Utils.polarToCartesian(
opts.centerX,
opts.centerY,
inner,
angle,
)
const p2 = Utils.polarToCartesian(
opts.centerX,
opts.centerY,
outer,
angle,
)
const line = graphics.drawLine(
p1.x,
p1.y,
p2.x,
p2.y,
cfg.color || (isMajor ? '#666' : '#999'),
0,
cfg.width || (isMajor ? 2 : 1),
)
g.add(line)
if (isMajor && ticks.labels?.show) {
const labelRadius =
(cfg.placement === 'inside' ? inner : outer) +
(cfg.placement === 'inside' ? -1 : 1) *
(ticks.labels.offset ?? 6)
const labelPos = Utils.polarToCartesian(
opts.centerX,
opts.centerY,
labelRadius,
angle,
)
const labelText =
typeof ticks.labels.formatter === 'function'
? ticks.labels.formatter(value)
: String(value)
const elText = graphics.drawText({
x: labelPos.x,
y: labelPos.y,
text: labelText,
textAnchor: 'middle',
dominantBaseline: 'middle',
fontFamily: ticks.labels.fontFamily,
fontSize: ticks.labels.fontSize,
fontWeight: ticks.labels.fontWeight,
foreColor: ticks.labels.color,
cssClass: 'apexcharts-gauge-tick-label',
})
g.add(elText)
}
}
for (let m = 0; m < majorCount; m++) {
const t = m / (majorCount - 1)
const value = min + t * (max - min)
drawTickAt(value, ticks.major || {}, true)
// Minor ticks fall between this major and the next.
if (m < majorCount - 1 && minorCount > 0) {
for (let n = 1; n <= minorCount; n++) {
const tMinor = (m + n / (minorCount + 1)) / (majorCount - 1)
const minorValue = min + tMinor * (max - min)
drawTickAt(minorValue, ticks.minor || {}, false)
}
}
}
return g
}
/**
* Draw a rotating needle pointing at the current series value. Only
* called when `plotOptions.radialBar.shape === 'needle'`. The needle is
* a tapered polygon inside a `<g>` whose rotation transform is animated
* from `startAngle` to the value's angle.
*
* Renders a single needle for the first series value (gauge use case).
* Additional series are ignored — drilled-down multi-series gauges are
* out of scope for this iteration.
*
* @param {Record<string, any>} opts
*/
drawNeedle(opts) {
const w = this.w
const graphics = new Graphics(this.w)
const rb = w.config.plotOptions.radialBar
const cfg = rb.needle || {}
const g = graphics.group({ class: 'apexcharts-gauge-needle' })
if (!opts.series || opts.series.length === 0) return g
const strokeWidth = this.getStrokeWidth(opts)
const arcRadius = opts.size - strokeWidth / 2 - strokeWidth - this.margin
const length =
typeof cfg.length === 'string' && cfg.length.endsWith('%')
? (arcRadius * parseInt(cfg.length, 10)) / 100
: Number(cfg.length || arcRadius * 0.85)
const baseW = cfg.baseWidth ?? 4
const tipW = cfg.tipWidth ?? 1
const color = cfg.color || '#333'
// Build the needle as a tapered shape with a rounded (semi-circular)
// base. The base center sits at (centerX, centerY + needle.offsetY);
// needle points straight up at angle 0 in our polar system. We rotate
// the wrapping <g> to position it around the (offset) base point.
const cx = opts.centerX
const needleOffsetY = Number(cfg.offsetY ?? 0)
const cy = opts.centerY + needleOffsetY
// Path: right base → semicircular arc clockwise around the base (bulges
// below the baseline, giving a rounded "anchor" look) → left base → up
// to left tip → across to right tip → close.
const path =
`M ${cx + baseW / 2} ${cy} ` +
`A ${baseW / 2} ${baseW / 2} 0 0 1 ${cx - baseW / 2} ${cy} ` +
`L ${cx - tipW / 2} ${cy - length} ` +
`L ${cx + tipW / 2} ${cy - length} Z`
const elNeedle = graphics.drawPath({
d: path,
stroke: color,
strokeWidth: 0,
fill: color,
classes: 'apexcharts-gauge-needle-shape',
})
g.add(elNeedle)
// Rotate the whole group around the base point.
const value = Number(opts.series[0])
const targetAngle = this._angleAtValue(value)
const isInitialMount =
this.initialAnim && !w.globals.dataChanged && !w.globals.resized
// Cache the previous angle on the chart instance so updateSeries can
// tween from it. First update after mount tweens from `startAngle`.
const ctx = /** @type {any} */ (this.ctx)
const fromAngle =
typeof ctx._lastNeedleAngle === 'number'
? ctx._lastNeedleAngle
: this.startAngle
ctx._lastNeedleAngle = targetAngle
const shouldAnimate =
Environment.isBrowser() &&
w.globals.shouldAnimate &&
(isInitialMount || w.globals.dataChanged)
if (shouldAnimate && fromAngle !== targetAngle) {
// Ease-out-back on initial mount (spring-loaded settle); plain ease-out
// on data updates (no overshoot — feels mechanical/instrument-like).
const node = g.node
node.setAttribute('transform-origin', `${cx} ${cy}`)
node.setAttribute('transform', `rotate(${fromAngle})`)
const speed =
(cfg.animation?.duration && Number(cfg.animation.duration)) ||
(cfg.animationSpeed && Number(cfg.animationSpeed)) ||
w.config.chart.animations.dynamicAnimation?.speed ||
w.config.chart.animations.speed ||
800
const c1 = 1.70158
const c3 = c1 + 1
/** @param {number} t */
const easeOutBack = (t) =>
1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
/** @param {number} t */
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3)
const ease = isInitialMount ? easeOutBack : easeOutCubic
const startAt = performance.now()
/** @param {number} now */
const step = (now) => {
const t = Math.max(0, Math.min(1, (now - startAt) / speed))
const angle = fromAngle + (targetAngle - fromAngle) * ease(t)
node.setAttribute('transform', `rotate(${angle})`)
if (t < 1) BrowserAPIs.requestAnimationFrame(step)
}
BrowserAPIs.requestAnimationFrame(step)
} else {
g.attr({
'transform-origin': `${cx} ${cy}`,
transform: `rotate(${targetAngle})`,
})
}
return g
}
/**
* @param {Record<string, any>} opts
*/
drawHollow(opts) {
const graphics = new Graphics(this.w)
const hollow = this.w.config.plotOptions.radialBar.hollow
const circle = graphics.drawCircle(opts.size * 2)
/** @type {Record<string, any>} */
const attrs = {
class: 'apexcharts-radialbar-hollow',
cx: opts.centerX,
cy: opts.centerY,
r: opts.size,
fill: opts.fill,
}
// Optional stroke around the hollow ring — useful for indicator-style
// gauges where the value text sits inside a dashed/dotted circle.
if (hollow.stroke || hollow.strokeDasharray) {
attrs.stroke = hollow.stroke || 'transparent'
attrs['stroke-width'] = hollow.strokeWidth ?? 1
if (hollow.strokeDasharray) {
attrs['stroke-dasharray'] = hollow.strokeDasharray
}
}
circle.attr(attrs)
return circle
}
/**
* @param {Record<string, any>} opts
* @param {any} g
* @param {number} hollowSize
* @param {string} hollowFillID
*/
drawHollowImage(opts, g, hollowSize, hollowFillID) {
const w = this.w
const fill = new Fill(this.w)
const randID = Utils.randomId()
const hollowFillImg = w.config.plotOptions.radialBar.hollow.image
if (w.config.plotOptions.radialBar.hollow.imageClipped) {
fill.clippedImgArea({
width: hollowSize,
height: hollowSize,
image: hollowFillImg,
patternID: `pattern${w.globals.cuid}${randID}`,
})
hollowFillID = `url(#pattern${w.globals.cuid}${randID})`
} else {
const imgWidth = w.config.plotOptions.radialBar.hollow.imageWidth
const imgHeight = w.config.plotOptions.radialBar.hollow.imageHeight
if (imgWidth === undefined && imgHeight === undefined) {
/**
* @param {Record<string, any>} loader
*/
const image = w.dom.Paper.image(
hollowFillImg,
/** @this {any} */
function (/** @type {Record<string, any>} */ loader) {
this.move(
opts.centerX -
loader.width / 2 +
w.config.plotOptions.radialBar.hollow.imageOffsetX,
opts.centerY -
loader.height / 2 +
w.config.plotOptions.radialBar.hollow.imageOffsetY,
)
},
)
g.add(image)
} else {
const image = w.dom.Paper.image(
hollowFillImg,
/** @this {any} */ function () {
this.move(
opts.centerX -
imgWidth / 2 +
w.config.plotOptions.radialBar.hollow.imageOffsetX,
opts.centerY -
imgHeight / 2 +
w.config.plotOptions.radialBar.hollow.imageOffsetY,
)
this.size(imgWidth, imgHeight)
},
)
g.add(image)
}
}
return hollowFillID
}
/**
* @param {Record<string, any>} opts
*/
getStrokeWidth(opts) {
const w = this.w
return (
(opts.size *
(100 - parseInt(w.config.plotOptions.radialBar.hollow.size, 10))) /
100 /
(opts.series.length + 1) -
this.margin
)
}
/**
* @param {Event} e
*/
onBarLabelClick(e) {
const target = /** @type {Element} */ (e.target)
const seriesIndex = parseInt(target.getAttribute('rel') ?? '', 10) - 1
const legendClick = this.barLabels.onClick
const w = this.w
if (legendClick) {
legendClick(w.seriesData.seriesNames[seriesIndex], { w, seriesIndex })
}
}
}
export default Radial