apexcharts
Version:
A JavaScript Chart Library
763 lines (664 loc) • 23.1 kB
JavaScript
// @ts-check
import Graphics from './Graphics'
import Utils from '../utils/Utils'
import { captureStreamFrame } from './animations/StreamScroll'
import { captureAxisChrome } from './animations/AxisTransition'
import { captureDataLabels } from './animations/DataLabelTransition'
/**
* ApexCharts Series Class for interaction with the Series of the chart.
*
* @module Series
**/
export default class Series {
/**
* @param {import('../types/internal').ChartStateW} w
* @param {{ toggleDataSeries?: Function, revertDefaultAxisMinMax?: Function, updateSeries?: Function }} [callbacks]
*/
constructor(
w,
{
toggleDataSeries = undefined,
revertDefaultAxisMinMax = undefined,
updateSeries = undefined,
} = {},
) {
this.w = w
// Injected callbacks for cross-module coordination (toggleSeries/showSeries/hideSeries/resetSeries)
/** @type {Function | null} */ this._toggleDataSeries =
toggleDataSeries || null
/** @type {Function | null} */ this._revertDefaultAxisMinMax =
revertDefaultAxisMinMax || null
/** @type {Function | null} */ this._updateSeries = updateSeries || null
this.legendInactiveClass = 'legend-mouseover-inactive'
}
clearSeriesCache() {
const w = this.w
if (w.globals.cachedSelectors) {
delete w.globals.cachedSelectors.allSeriesEls
delete w.globals.cachedSelectors.highlightSeriesEls
}
}
getAllSeriesEls() {
// cache the result to avoid repeated querySelectorAll
const w = this.w
const cacheKey = 'allSeriesEls'
if (!w.globals.cachedSelectors[cacheKey]) {
w.globals.cachedSelectors[cacheKey] = /** @type {any} */ (
w.dom.baseEl.getElementsByClassName(`apexcharts-series`)
)
}
return w.globals.cachedSelectors[cacheKey]
}
/**
* @param {string} seriesName
*/
getSeriesByName(seriesName) {
return this.w.dom.baseEl.querySelector(
`.apexcharts-inner .apexcharts-series[seriesName='${Utils.escapeString(
seriesName,
)}']`,
)
}
/**
* @param {string} seriesName
*/
isSeriesHidden(seriesName) {
const targetElement = this.getSeriesByName(seriesName)
const el = /** @type {Element} */ (targetElement)
const realIndex = parseInt(el.getAttribute('data:realIndex') ?? '0', 10)
const isHidden = el.classList.contains('apexcharts-series-collapsed')
return { isHidden, realIndex }
}
/**
* @param {any} elSeries
* @param {number} index
*/
addCollapsedClassToSeries(elSeries, index) {
Series.addCollapsedClassToSeries(this.w, elSeries, index)
}
/**
* @param {import('../types/internal').ChartStateW} w
* @param {any} elSeries
* @param {number} index
*/
static addCollapsedClassToSeries(w, elSeries, index) {
/**
* @param {any[]} series
*/
function iterateOnAllCollapsedSeries(series) {
for (let cs = 0; cs < series.length; cs++) {
if (series[cs].index === index) {
elSeries.node.classList.add('apexcharts-series-collapsed')
}
}
}
iterateOnAllCollapsedSeries(w.globals.collapsedSeries)
iterateOnAllCollapsedSeries(w.globals.ancillaryCollapsedSeries)
}
/**
* @param {string} seriesName
*/
toggleSeries(seriesName) {
const isSeriesHidden = this.isSeriesHidden(seriesName)
this._toggleDataSeries?.(isSeriesHidden.realIndex, isSeriesHidden.isHidden)
return isSeriesHidden.isHidden
}
/**
* @param {string} seriesName
*/
showSeries(seriesName) {
const isSeriesHidden = this.isSeriesHidden(seriesName)
if (isSeriesHidden.isHidden) {
this._toggleDataSeries?.(isSeriesHidden.realIndex, true)
}
}
/**
* @param {string} seriesName
*/
hideSeries(seriesName) {
const isSeriesHidden = this.isSeriesHidden(seriesName)
if (!isSeriesHidden.isHidden) {
this._toggleDataSeries?.(isSeriesHidden.realIndex, false)
}
}
/**
* Cheap pre-update reset for the data-replacement paths (updateSeries /
* appendSeries). Clears the same bookkeeping resetSeries() clears (series
* cache, previous paths, collapsed-series state) WITHOUT restoring
* config.series from the initialSeries snapshot: the caller is about to
* replace the series anyway (parseData assigns config.series = newSeries),
* so materializing and cloning the snapshot per update is pure waste (two
* O(n) deep clones per streaming tick at 50k points).
*/
prepareDataUpdate() {
const w = this.w
this.clearSeriesCache()
w.globals.previousPaths = []
w.globals.collapsedSeries = []
w.globals.ancillaryCollapsedSeries = []
w.globals.collapsedSeriesIndices = []
w.globals.ancillaryCollapsedSeriesIndices = []
}
resetSeries(
shouldUpdateChart = true,
shouldResetZoom = true,
shouldResetCollapsed = true,
) {
const w = this.w
this.clearSeriesCache()
let series = Utils.clone(w.globals.initialSeries)
w.globals.previousPaths = []
if (shouldResetCollapsed) {
w.globals.collapsedSeries = []
w.globals.ancillaryCollapsedSeries = []
w.globals.collapsedSeriesIndices = []
w.globals.ancillaryCollapsedSeriesIndices = []
} else {
series = this.emptyCollapsedSeries(series)
}
w.config.series = series
if (shouldUpdateChart) {
if (shouldResetZoom) {
w.interact.zoomed = false
this._revertDefaultAxisMinMax?.()
}
this._updateSeries?.(
series,
w.config.chart.animations.dynamicAnimation.enabled,
)
}
}
/**
* @param {any[]} series
*/
emptyCollapsedSeries(series) {
const w = this.w
for (let i = 0; i < series.length; i++) {
if (w.globals.collapsedSeriesIndices.indexOf(i) > -1) {
// Axis charts carry {name, data}; non-axis charts (pie / donut / unit)
// carry a bare numeric value. Empty each in its own shape: setting
// `.data` on a number throws ("Cannot create property 'data' on number"),
// which used to abort an in-flight update (e.g. a storyboard beat) once a
// series had been collapsed via a legend toggle.
if (series[i] && typeof series[i] === 'object') {
series[i].data = []
} else {
series[i] = 0
}
}
}
return series
}
/**
* Series display names for the CURRENT `w.config` (post-merge), derived the
* same way the parser does: an object series' own `name`, else the matching
* `labels` entry (non-axis / pie / unit), else a generated `series-N`.
* @returns {string[]}
*/
_deriveSeriesNames() {
const w = this.w
const series = w.config.series || []
const labels = w.config.labels || []
return series.map((/** @type {any} */ s, /** @type {number} */ i) => {
if (s && typeof s === 'object' && s.name != null) return String(s.name)
return labels[i] != null ? String(labels[i]) : `series-${i + 1}`
})
}
/**
* Re-apply legend-hidden (collapsed) series to a freshly-updated
* `w.config.series`, matching BY CATEGORY NAME rather than index. This keeps a
* hide alive across a data update that reorders or regroups categories - most
* visibly a storyboard beat that supplies new series each scroll:
* - a category still present stays hidden (at its possibly-new index);
* - a category the update dropped/regrouped away is un-hidden (it no longer
* exists, so it must reappear as part of the new grouping).
* Records without a stored name (older collapses) fall back to their index.
*/
reconcileCollapsedByName() {
const w = this.w
const gl = w.globals
const newNames = this._deriveSeriesNames()
/**
* @param {any[]} records
* @returns {{ records: any[], indices: number[] }}
*/
const reconcile = (records) => {
/** @type {any[]} */ const nextRecords = []
/** @type {number[]} */ const nextIndices = []
records.forEach((rec) => {
const j =
rec && rec.name != null ? newNames.indexOf(rec.name) : rec.index
// Category gone (regrouped away) -> drop the collapse so it reappears.
if (j == null || j < 0 || j >= w.config.series.length) return
const s = /** @type {any} */ (w.config.series[j])
rec.index = j
// Refresh the stored data to the NEW value so a later rise restores the
// current data, not the stale snapshot from when it was first hidden.
rec.data = gl.axisCharts ? (s && s.data ? s.data.slice() : []) : s
nextRecords.push(rec)
nextIndices.push(j)
})
return { records: nextRecords, indices: nextIndices }
}
const main = reconcile(gl.collapsedSeries)
gl.collapsedSeries = main.records
gl.collapsedSeriesIndices = main.indices
const anc = reconcile(gl.ancillaryCollapsedSeries)
gl.ancillaryCollapsedSeries = anc.records
gl.ancillaryCollapsedSeriesIndices = anc.indices
gl.allSeriesCollapsed =
gl.collapsedSeries.length + gl.ancillaryCollapsedSeries.length ===
w.config.series.length
this.emptyCollapsedSeries(w.config.series)
}
/**
* @param {string} seriesName
*/
/**
* Bridge SVG series-dim state to the canvas renderer: SVG opacity classes
* (legend-mouseover-inactive) don't touch the painted canvas series layer, so
* repaint it with a matching per-series opacity. No-op unless the canvas
* renderer is active. The renderer is mirrored on globals by RendererController
* (Series has no ctx handle).
* @param {{active:number, opacity:number}|null} dim
*/
canvasRestyle(dim) {
const r = this.w.globals.activeRenderer
if (r && r.kind === 'canvas' && typeof r.restyle === 'function') {
r.restyle(dim)
}
}
/**
* @param {string} seriesName
*/
highlightSeries(seriesName) {
const w = this.w
const targetElement = this.getSeriesByName(seriesName)
const realIndex = parseInt(
targetElement?.getAttribute('data:realIndex') ?? '',
10,
)
const cacheKey = 'highlightSeriesEls'
let allSeriesEls = w.globals.cachedSelectors[cacheKey]
if (!allSeriesEls) {
allSeriesEls = w.dom.baseEl.querySelectorAll(
`.apexcharts-series, .apexcharts-datalabels, .apexcharts-yaxis`,
)
w.globals.cachedSelectors[cacheKey] = allSeriesEls
}
let seriesEl = null
let dataLabelEl = null
let yaxisEl = null
if (w.globals.axisCharts || w.config.chart.type === 'radialBar') {
if (w.globals.axisCharts) {
seriesEl = w.dom.baseEl.querySelector(
`.apexcharts-series[data\\:realIndex='${realIndex}']`,
)
dataLabelEl = w.dom.baseEl.querySelector(
`.apexcharts-datalabels[data\\:realIndex='${realIndex}']`,
)
const yaxisIndex = w.globals.seriesYAxisReverseMap[realIndex]
yaxisEl = w.dom.baseEl.querySelector(
`.apexcharts-yaxis[rel='${yaxisIndex}']`,
)
} else {
seriesEl = w.dom.baseEl.querySelector(
`.apexcharts-series[rel='${realIndex + 1}']`,
)
}
} else {
seriesEl = w.dom.baseEl.querySelector(
`.apexcharts-series[rel='${realIndex + 1}'] path`,
)
}
for (let se = 0; se < allSeriesEls.length; se++) {
const serEl = /** @type {Element} */ (allSeriesEls[se])
serEl.classList.add(this.legendInactiveClass)
}
if (seriesEl) {
if (!w.globals.axisCharts) {
const parentEl = /** @type {Element} */ (seriesEl.parentNode)
parentEl?.classList.remove(this.legendInactiveClass)
}
seriesEl.classList.remove(this.legendInactiveClass)
if (dataLabelEl !== null) {
dataLabelEl.classList.remove(this.legendInactiveClass)
}
if (yaxisEl !== null) {
yaxisEl.classList.remove(this.legendInactiveClass)
}
} else {
for (let se = 0; se < allSeriesEls.length; se++) {
const serEl = /** @type {Element} */ (allSeriesEls[se])
serEl.classList.remove(this.legendInactiveClass)
}
}
this.canvasRestyle(
seriesEl && !Number.isNaN(realIndex)
? { active: realIndex, opacity: 0.2 }
: null,
)
}
/**
* @param {Event} e
* @param {any} targetElement
*/
toggleSeriesOnHover(e, targetElement) {
const w = this.w
if (!targetElement) targetElement = e.target
const allSeriesEls = w.dom.baseEl.querySelectorAll(
`.apexcharts-series, .apexcharts-datalabels, .apexcharts-yaxis`,
)
if (e.type === 'mousemove') {
const realIndex = parseInt(targetElement.getAttribute('rel'), 10) - 1
this.highlightSeries(w.seriesData.seriesNames[realIndex])
} else if (e.type === 'mouseout') {
for (let se = 0; se < allSeriesEls.length; se++) {
allSeriesEls[se].classList.remove(this.legendInactiveClass)
}
this.canvasRestyle(null)
}
}
/**
* Dim every heatmap cell except those whose value falls inside the color
* range at `rangeIndex`. Shared by the categorical legend (hover a legend
* item) and the gradient legend (hover a band) — both supply an index into
* `colorScale.ranges` plus an action, decoupling the highlight from any
* particular DOM element / event shape.
*
* @param {number} rangeIndex index into `colorScale.ranges`
* @param {'highlight'|'reset'} action
*/
highlightRangeInSeries(rangeIndex, action) {
const w = this.w
const allHeatMapElements = w.dom.baseEl.getElementsByClassName(
'apexcharts-heatmap-rect',
)
/**
* @param {'add'|'remove'} op
*/
const toggleAllInactive = (op) => {
for (let i = 0; i < allHeatMapElements.length; i++) {
const classList = /** @type {any} */ (allHeatMapElements[i]).classList
if (typeof classList[op] === 'function') {
classList[op](this.legendInactiveClass)
}
}
}
if (action === 'reset') {
toggleAllInactive('remove')
return
}
const ranges = w.config.plotOptions.heatmap.colorScale.ranges
const range = ranges && ranges[rangeIndex]
if (!range) return
toggleAllInactive('add')
for (let i = 0; i < allHeatMapElements.length; i++) {
const val = Number(allHeatMapElements[i].getAttribute('val'))
// Match the inclusive bounds used by determineColor (treemap/Helpers.js)
// so the same cells that were colored by this range get highlighted.
if (val >= range.from && val <= range.to) {
allHeatMapElements[i].classList.remove(this.legendInactiveClass)
}
}
}
/**
* @param {string[]} chartTypes
*/
getActiveConfigSeriesIndex(order = 'asc', chartTypes = []) {
const w = this.w
let activeIndex = 0
if (w.config.series.length > 1) {
// active series flag is required to know if user has not deactivated via legend click
/**
* @param {Record<string, any>} s
* @param {number} index
*/
const activeSeriesIndex = w.config.series.map((s, index) => {
const checkChartType = () => {
if (w.globals.comboCharts) {
return (
chartTypes.length === 0 ||
(chartTypes.length &&
chartTypes.indexOf(
/** @type {Record<string,any>} */ (w.config.series[index])
.type,
) > -1)
)
}
return true
}
const hasData =
/** @type {any} */ (s).data &&
/** @type {any} */ (s).data.length > 0 &&
w.globals.collapsedSeriesIndices.indexOf(index) === -1
return hasData && checkChartType() ? index : -1
})
for (
let a = order === 'asc' ? 0 : activeSeriesIndex.length - 1;
order === 'asc' ? a < activeSeriesIndex.length : a >= 0;
order === 'asc' ? a++ : a--
) {
if (activeSeriesIndex[a] !== -1) {
activeIndex = activeSeriesIndex[a]
break
}
}
}
return activeIndex
}
getBarSeriesIndices() {
const w = this.w
if (w.globals.comboCharts) {
return this.w.config.series
.map((/** @type {any} */ s, /** @type {number} */ i) => {
return s.type === 'bar' || s.type === 'column' ? i : -1
})
.filter((/** @type {number} */ i) => {
return i !== -1
})
}
/**
* @param {Record<string, any>} s
* @param {number} i
*/
return this.w.config.series.map((s, i) => {
return i
})
}
getPreviousPaths() {
const w = this.w
// Streaming scroll: snapshot the outgoing frame's parsed rows + pixel
// positions so a windowed-continuation update (rolling window / append
// under xaxis.range) can be animated as a slide. See StreamScroll.
captureStreamFrame(w)
// Axis-chrome snapshot: tick labels + gridlines of the outgoing render,
// so a variable-length update can slide/fade the ruler along with the
// reflowing marks. See AxisTransition.
captureAxisChrome(w)
// Data-label snapshot (opt-in): position + value of bar/column labels, so
// a data-change update can ride labels to their new slot and count their
// value up. See DataLabelTransition. No-op unless the feature is on.
captureDataLabels(w)
// Non-axis charts (pie/donut/radialBar) overwrite previousPaths with the
// raw series values at the end anyway — skip the DOM captures entirely.
if (!w.globals.axisCharts) {
w.globals.previousPaths = w.seriesData.series
return
}
// A synced sibling chart (see getSyncedCharts()) can land here mid
// teardown/rebuild: a wrapping component (e.g. a templating card that
// re-renders its child on every data-driven config change) may have
// already detached this chart's host element - or never finished
// mounting it - while this chart's own _updateOptions() call is still
// in flight. Either way there's no live DOM to capture paths from.
if (!Utils.elementExists(w.dom.baseEl)) {
w.globals.previousPaths = []
return
}
w.globals.previousPaths = []
/**
* @param {any} seriesEls
* @param {number} i
* @param {string} type
*/
function pushPaths(seriesEls, i, type) {
const paths = seriesEls[i].childNodes
const dArr = {
type,
paths: /** @type {any[]} */ ([]),
realIndex: seriesEls[i].getAttribute('data:realIndex'),
}
for (let j = 0; j < paths.length; j++) {
if (paths[j].hasAttribute('pathTo')) {
const d = paths[j].getAttribute('pathTo')
// Datum key + fill stamped by the bar renderer: the key lets the
// next render match survivors by identity (not position) and detect
// exited datums; the fill paints their exit ghosts.
dArr.paths.push({
d,
key: paths[j].getAttribute('data:pathKey'),
fill: paths[j].getAttribute('fill'),
})
}
}
w.globals.previousPaths.push(dArr)
}
/**
* @param {string} chartType
*/
const getPaths = (chartType) => {
return w.dom.baseEl.querySelectorAll(
`.apexcharts-${chartType}-series .apexcharts-series`,
)
}
const chartTypes = [
'line',
'area',
'bar',
'rangebar',
'rangeArea',
'candlestick',
'radar',
]
chartTypes.forEach((type) => {
const paths = getPaths(type)
for (let p = 0; p < paths.length; p++) {
pushPaths(paths, p, type)
}
})
const heatTreeSeries = w.dom.baseEl.querySelectorAll(
`.apexcharts-${w.config.chart.type} .apexcharts-series`,
)
if (heatTreeSeries.length > 0) {
for (let h = 0; h < heatTreeSeries.length; h++) {
const seriesEls = w.dom.baseEl.querySelectorAll(
`.apexcharts-${w.config.chart.type} .apexcharts-series[data\\:realIndex='${h}'] rect`,
)
const dArr = []
for (let i = 0; i < seriesEls.length; i++) {
/**
* @param {number} x
*/
const getAttr = (/** @type {string} */ x) => {
return /** @type {Element} */ (seriesEls[i]).getAttribute(x)
}
const rect = {
x: parseFloat(getAttr('x') ?? '0'),
y: parseFloat(getAttr('y') ?? '0'),
width: parseFloat(getAttr('width') ?? '0'),
height: parseFloat(getAttr('height') ?? '0'),
}
dArr.push({
rect,
color: seriesEls[i].getAttribute('color'),
})
}
w.globals.previousPaths.push(dArr)
}
}
}
clearPreviousPaths() {
const w = this.w
w.globals.previousPaths = []
w.globals.allSeriesCollapsed = false
}
handleNoData() {
const w = this.w
const me = this
const noDataOpts = w.config.noData
const graphics = new Graphics(me.w)
let x = w.globals.svgWidth / 2
let y = w.globals.svgHeight / 2
let textAnchor = 'middle'
w.globals.noData = true
w.globals.animationEnded = true
if (noDataOpts.align === 'left') {
x = 10
textAnchor = 'start'
} else if (noDataOpts.align === 'right') {
x = w.globals.svgWidth - 10
textAnchor = 'end'
}
if (noDataOpts.verticalAlign === 'top') {
y = 50
} else if (noDataOpts.verticalAlign === 'bottom') {
y = w.globals.svgHeight - 50
}
x = x + noDataOpts.offsetX
y = y + parseInt(noDataOpts.style.fontSize, 10) + 2 + noDataOpts.offsetY
if (noDataOpts.text !== undefined && noDataOpts.text !== '') {
const titleText = graphics.drawText({
x,
y,
text: noDataOpts.text,
textAnchor,
fontSize: noDataOpts.style.fontSize,
fontFamily: noDataOpts.style.fontFamily,
foreColor: noDataOpts.style.color,
opacity: 1,
cssClass: 'apexcharts-text-nodata',
})
w.dom.Paper.add(titleText)
}
}
// When user clicks on legends, the collapsed series is filled with [0,0,0,...,0]
// This is because we don't want to alter the series' length as it is used at many places
/**
* @param {any[]} series
*/
setNullSeriesToZeroValues(series) {
const w = this.w
for (let sl = 0; sl < series.length; sl++) {
if (series[sl].length === 0) {
for (let j = 0; j < series[w.globals.maxValsInArrayIndex].length; j++) {
series[sl].push(0)
}
}
}
return series
}
hasAllSeriesEqualX() {
let equalLen = true
const w = this.w
const filteredSerX = this.filteredSeriesX()
for (let i = 0; i < filteredSerX.length - 1; i++) {
if (filteredSerX[i][0] !== filteredSerX[i + 1][0]) {
equalLen = false
break
}
}
w.globals.allSeriesHasEqualX = equalLen
return equalLen
}
filteredSeriesX() {
const w = this.w
/**
* @param {any[]} ser
*/
const filteredSeriesX = w.seriesData.seriesX.map((ser) =>
ser.length > 0 ? ser : [],
)
return filteredSeriesX
}
}