UNPKG

apexcharts

Version:

A JavaScript Chart Library

765 lines (685 loc) 22.9 kB
// @ts-check import CoreUtils from './CoreUtils' import Crosshairs from './Crosshairs' import Globals from '../modules/settings/Globals' import Graphics from './Graphics' import Range from './Range' import Utils from '../utils/Utils' import TimeScale from './TimeScale' import { Environment } from '../utils/Environment.js' import { BrowserAPIs } from '../ssr/BrowserAPIs.js' import { SVGNS } from '../svg/math' import { getChartClass } from './ChartFactory' /** * ApexCharts Core Class responsible for major calculations and creating elements. * * @module Core **/ export default class Core { /** * @param {Element} el * @param {import('../types/internal').ChartStateW} w * @param {import('../types/internal').ChartContext} ctx */ constructor(el, w, ctx) { this.w = w this.ctx = ctx // needed: timeScale, updateHelpers, chart type instantiation this.el = el } setupElements() { const { globals: gl, config: cnf } = this.w const ct = cnf.chart.type const xyChartsArrTypes = [ 'line', 'area', 'bar', 'rangeBar', 'rangeArea', 'candlestick', 'boxPlot', 'scatter', 'bubble', ] const axisChartsArrTypes = [ ...xyChartsArrTypes, 'radar', 'heatmap', 'treemap', ] gl.axisCharts = axisChartsArrTypes.includes(ct) gl.xyCharts = xyChartsArrTypes.includes(ct) gl.isBarHorizontal = ['bar', 'rangeBar', 'boxPlot'].includes(ct) && cnf.plotOptions.bar.horizontal gl.chartClass = `.apexcharts${gl.chartID}` this.w.dom.baseEl = this.el this.w.dom.elWrap = BrowserAPIs.createElementNS( 'http://www.w3.org/1999/xhtml', 'div', ) Graphics.setAttrs(this.w.dom.elWrap, { id: gl.chartClass.substring(1), class: `apexcharts-canvas ${gl.chartClass.substring(1)}`, }) this.el.appendChild(this.w.dom.elWrap) // this.w.dom.Paper = new window.SVG.Doc(this.w.dom.elWrap) // Access SVG from appropriate global scope const SVG = Environment.isBrowser() ? /** @type {any} */ (window).SVG : /** @type {any} */ (global).SVG this.w.dom.Paper = SVG().addTo(this.w.dom.elWrap) this.w.dom.Paper.attr({ class: 'apexcharts-svg', 'xmlns:data': 'ApexChartsNS', transform: `translate(${cnf.chart.offsetX}, ${cnf.chart.offsetY})`, }) this.w.dom.Paper.node.style.background = cnf.theme.mode === 'dark' && !cnf.chart.background ? '#343A3F' : cnf.theme.mode === 'light' && !cnf.chart.background ? '#fff' : cnf.chart.background this.setSVGDimensions() // foreignObject must be added first (at the back in z-order) to prevent blocking interactions this.w.dom.elLegendForeign = BrowserAPIs.createElementNS( SVGNS, 'foreignObject', ) Graphics.setAttrs(this.w.dom.elLegendForeign, { x: 0, y: 0, width: gl.svgWidth, height: gl.svgHeight, }) this.w.dom.elLegendWrap = BrowserAPIs.createElementNS( 'http://www.w3.org/1999/xhtml', 'div', ) this.w.dom.elLegendWrap.classList.add('apexcharts-legend') this.w.dom.elWrap.appendChild(this.w.dom.elLegendWrap) this.w.dom.Paper.node.appendChild(this.w.dom.elLegendForeign) // Add accessibility elements after foreignObject to maintain proper z-order if (cnf.chart.accessibility.enabled) { const ariaLabel = this.getAccessibleChartLabel() // Use role="application" when keyboard navigation is enabled so that // screen readers pass arrow keys through to the chart rather than // intercepting them. const svgRole = cnf.chart.accessibility.keyboard.enabled && cnf.chart.accessibility.keyboard.navigation.enabled ? 'application' : 'img' this.w.dom.Paper.attr({ role: svgRole, 'aria-label': ariaLabel, }) // Add desc element when description is provided if (cnf.chart.accessibility.description) { const descEl = BrowserAPIs.createElementNS(SVGNS, 'desc') descEl.textContent = cnf.chart.accessibility.description this.w.dom.Paper.node.insertBefore( descEl, this.w.dom.elLegendForeign.nextSibling, ) } } this.w.dom.elGraphical = this.w.dom.Paper.group().attr({ class: 'apexcharts-inner apexcharts-graphical', }) this.w.dom.elDefs = this.w.dom.Paper.defs() this.w.dom.Paper.add(this.w.dom.elGraphical) this.w.dom.elGraphical.add(this.w.dom.elDefs) } /** * @param {any[]} ser * @param {import('../types/internal').XYRatios} xyRatios */ plotChartType(ser, xyRatios) { const { w, ctx } = this const { config: cnf, globals: gl } = w const seriesTypes = { line: { series: [], i: [] }, area: { series: [], i: [] }, scatter: { series: [], i: [] }, bubble: { series: [], i: [] }, bar: { series: [], i: [] }, candlestick: { series: [], i: [] }, boxPlot: { series: [], i: [] }, rangeBar: { series: [], i: [] }, rangeArea: { series: [], seriesRangeEnd: [], i: [] }, } const chartType = cnf.chart.type || 'line' let nonComboType = null let comboCount = 0 /** * @param {Object} serie * @param {number} st */ this.w.seriesData.series.forEach((serie, st) => { const seriesType = ser[st]?.type === 'column' ? 'bar' : ser[st]?.type || (chartType === 'column' ? 'bar' : chartType) if (/** @type {Record<string,any>} */ (seriesTypes)[seriesType]) { if (seriesType === 'rangeArea') { ;/** @type {Record<string,any>} */ (seriesTypes)[ seriesType ].series.push(this.w.rangeData.seriesRangeStart[st]) ;/** @type {Record<string,any>} */ (seriesTypes)[ seriesType ].seriesRangeEnd.push(this.w.rangeData.seriesRangeEnd[st]) } else { ;/** @type {Record<string,any>} */ (seriesTypes)[ seriesType ].series.push(serie) } ;/** @type {Record<string,any>} */ (seriesTypes)[seriesType].i.push(st) if (seriesType === 'bar') w.globals.columnSeries = seriesTypes.bar } else if ( [ 'heatmap', 'treemap', 'pie', 'donut', 'polarArea', 'radialBar', 'radar', ].includes(seriesType) ) { nonComboType = seriesType } else { console.warn( `You have specified an unrecognized series type (${seriesType}).`, ) } if (chartType !== seriesType && seriesType !== 'scatter') comboCount++ }) if (comboCount > 0) { if (nonComboType) { console.warn( `Chart or series type ${nonComboType} cannot appear with other chart or series types.`, ) } if (seriesTypes.bar.series.length > 0 && cnf.plotOptions.bar.horizontal) { comboCount -= seriesTypes.bar.series.length seriesTypes.bar = { series: [], i: [] } w.globals.columnSeries = { series: [], i: [] } console.warn( 'Horizontal bars are not supported in a mixed/combo chart. Please turn off `plotOptions.bar.horizontal`', ) } } gl.comboCharts ||= comboCount > 0 // Lazily resolve chart classes — only look up types that are actually used. // Eagerly calling getChartClass() for every type would break tree-shaking: // a page that only registers 'line' would throw when 'candlestick' etc. // are looked up even though they are never rendered. const needsLine = seriesTypes.line.series.length > 0 || seriesTypes.area.series.length > 0 || seriesTypes.scatter.series.length > 0 || seriesTypes.bubble.series.length > 0 || seriesTypes.rangeArea.series.length > 0 || (!gl.comboCharts && ['line', 'area', 'scatter', 'bubble', 'rangeArea'].includes( cnf.chart.type, )) const line = needsLine ? new (getChartClass('line'))(ctx.w, ctx, xyRatios) : null const needsCandlestick = seriesTypes.candlestick.series.length > 0 || seriesTypes.boxPlot.series.length > 0 || (!gl.comboCharts && ['candlestick', 'boxPlot'].includes(cnf.chart.type)) const boxCandlestick = needsCandlestick ? new (getChartClass('candlestick'))(ctx.w, ctx, xyRatios) : null const needsPie = !gl.comboCharts && ['pie', 'donut', 'polarArea'].includes(cnf.chart.type) ctx.pie = needsPie ? new (getChartClass('pie'))(ctx.w, ctx) : null const needsRangeBar = seriesTypes.rangeBar.series.length > 0 || (!gl.comboCharts && cnf.chart.type === 'rangeBar') ctx.rangeBar = needsRangeBar ? new (getChartClass('rangeBar'))(ctx.w, ctx, xyRatios) : null let elGraph = [] if (gl.comboCharts) { const coreUtils = new CoreUtils(this.w) if (seriesTypes.area.series.length > 0) { elGraph.push( ...coreUtils.drawSeriesByGroup( seriesTypes.area, gl.areaGroups, 'area', line, ), ) } if (seriesTypes.bar.series.length > 0) { if (cnf.chart.stacked) { const barStacked = new (getChartClass('barStacked'))( ctx.w, ctx, xyRatios, ) elGraph.push( barStacked.draw(seriesTypes.bar.series, seriesTypes.bar.i), ) } else { ctx.bar = new (getChartClass('bar'))(ctx.w, ctx, xyRatios) elGraph.push(ctx.bar.draw(seriesTypes.bar.series, seriesTypes.bar.i)) } } if (seriesTypes.rangeArea.series.length > 0) { elGraph.push( line.draw( seriesTypes.rangeArea.series, 'rangeArea', seriesTypes.rangeArea.i, seriesTypes.rangeArea.seriesRangeEnd, ), ) } if (seriesTypes.line.series.length > 0) { elGraph.push( ...coreUtils.drawSeriesByGroup( seriesTypes.line, gl.lineGroups, 'line', line, ), ) } if (seriesTypes.candlestick.series.length > 0) { elGraph.push( boxCandlestick.draw( seriesTypes.candlestick.series, 'candlestick', seriesTypes.candlestick.i, ), ) } if (seriesTypes.boxPlot.series.length > 0) { elGraph.push( boxCandlestick.draw( seriesTypes.boxPlot.series, 'boxPlot', seriesTypes.boxPlot.i, ), ) } if (seriesTypes.rangeBar.series.length > 0) { elGraph.push( ctx.rangeBar.draw( seriesTypes.rangeBar.series, seriesTypes.rangeBar.i, ), ) } if (seriesTypes.scatter.series.length > 0) { const scatterLine = new (getChartClass('line'))( ctx.w, ctx, xyRatios, true, ) elGraph.push( scatterLine.draw( seriesTypes.scatter.series, 'scatter', seriesTypes.scatter.i, ), ) } if (seriesTypes.bubble.series.length > 0) { const bubbleLine = new (getChartClass('line'))( ctx.w, ctx, xyRatios, true, ) elGraph.push( bubbleLine.draw( seriesTypes.bubble.series, 'bubble', seriesTypes.bubble.i, ), ) } } else { const type = cnf.chart.type switch (type) { case 'line': elGraph = line.draw(this.w.seriesData.series, 'line') break case 'area': elGraph = line.draw(this.w.seriesData.series, 'area') break case 'bar': if (cnf.chart.stacked) { const barStacked = new (getChartClass('barStacked'))( ctx.w, ctx, xyRatios, ) elGraph = barStacked.draw(this.w.seriesData.series) } else { ctx.bar = new (getChartClass('bar'))(ctx.w, ctx, xyRatios) elGraph = ctx.bar.draw(this.w.seriesData.series) } break case 'candlestick': elGraph = boxCandlestick.draw(this.w.seriesData.series, 'candlestick') break case 'boxPlot': elGraph = boxCandlestick.draw(this.w.seriesData.series, type) break case 'rangeBar': elGraph = ctx.rangeBar.draw(this.w.seriesData.series) break case 'rangeArea': elGraph = line.draw( this.w.rangeData.seriesRangeStart, 'rangeArea', undefined, this.w.rangeData.seriesRangeEnd, ) break case 'heatmap': { const heatmap = new (getChartClass('heatmap'))(ctx.w, ctx, xyRatios) elGraph = heatmap.draw(this.w.seriesData.series) break } case 'treemap': { const treemap = new (getChartClass('treemap'))(ctx.w, ctx) elGraph = treemap.draw(this.w.seriesData.series) break } case 'pie': case 'donut': case 'polarArea': elGraph = ctx.pie.draw(this.w.seriesData.series) break case 'radialBar': { const radialBar = new (getChartClass('radialBar'))(ctx.w, ctx) elGraph = radialBar.draw(this.w.seriesData.series) break } case 'radar': { const radar = new (getChartClass('radar'))(ctx.w, ctx) elGraph = radar.draw(this.w.seriesData.series) break } default: elGraph = line.draw(this.w.seriesData.series) } } return elGraph } setSVGDimensions() { const { globals: gl, config: cnf } = this.w cnf.chart.width = cnf.chart.width || '100%' cnf.chart.height = cnf.chart.height || 'auto' const rawWidth = cnf.chart.width const rawHeight = cnf.chart.height // Pre-set NaN so that when the element cannot be measured (e.g. JSDOM with // percentage width), svgWidth doesn't stay at the Globals default of 0. // The branching below overwrites with a real value when measurement works. // The original code achieved this by assigning the raw config string first // (e.g. '100%'). We use NaN instead to keep the type as number. gl.svgWidth = NaN gl.svgHeight = NaN let elDim = Utils.getDimensions(this.el) const widthUnit = rawWidth .toString() .split(/[0-9]+/g) .pop() if (widthUnit === '%') { if (Utils.isNumber(elDim[0])) { if (elDim[0].width === 0) { elDim = Utils.getDimensions(this.el.parentNode) } gl.svgWidth = (elDim[0] * parseInt(rawWidth, 10)) / 100 } } else if (widthUnit === 'px' || widthUnit === '') { gl.svgWidth = parseInt(rawWidth, 10) } const heightUnit = String(rawHeight) .toString() .split(/[0-9]+/g) .pop() if (rawHeight !== 'auto' && rawHeight !== '') { if (heightUnit === '%') { const elParentDim = Utils.getDimensions(this.el.parentNode) gl.svgHeight = (elParentDim[1] * parseInt(rawHeight, 10)) / 100 } else { gl.svgHeight = parseInt(rawHeight, 10) } } else { gl.svgHeight = gl.axisCharts ? gl.svgWidth / 1.61 : gl.svgWidth / 1.2 } gl.svgWidth = Math.max(gl.svgWidth, 0) gl.svgHeight = Math.max(gl.svgHeight, 0) Graphics.setAttrs(this.w.dom.Paper.node, { width: gl.svgWidth, height: gl.svgHeight, }) if (heightUnit !== '%' && Environment.isBrowser()) { const offsetY = cnf.chart.sparkline.enabled ? 0 : gl.axisCharts ? cnf.chart.parentHeightOffset : 0 const paperNode = this.w.dom.Paper.node if (paperNode.parentNode?.parentNode) { paperNode.parentNode.parentNode.style.minHeight = `${gl.svgHeight + offsetY}px` } } this.w.dom.elWrap.style.width = `${gl.svgWidth}px` this.w.dom.elWrap.style.height = `${gl.svgHeight}px` } shiftGraphPosition() { const { globals: gl } = this.w const { translateY: tY, translateX: tX } = gl Graphics.setAttrs(this.w.dom.elGraphical.node, { transform: `translate(${tX}, ${tY})`, }) } resizeNonAxisCharts() { const { w } = this let legendHeight = 0 let offY = w.config.chart.sparkline.enabled ? 1 : 15 offY += w.config.grid.padding.bottom if ( ['top', 'bottom'].includes(w.config.legend.position) && w.config.legend.show && !w.config.legend.floating ) { legendHeight = (this.ctx.legend?.legendHelpers.getLegendDimensions().clwh ?? 0) + 7 } const el = w.dom.baseEl.querySelector( '.apexcharts-radialbar, .apexcharts-pie', ) let chartInnerDimensions = w.globals.radialSize * 2.05 if ( el && !w.config.chart.sparkline.enabled && w.config.plotOptions.radialBar.startAngle !== 0 ) { const elRadialRect = Utils.getBoundingClientRect(el) chartInnerDimensions = elRadialRect.bottom const maxHeight = elRadialRect.bottom - elRadialRect.top chartInnerDimensions = Math.max(w.globals.radialSize * 2.05, maxHeight) } const newHeight = Math.ceil( chartInnerDimensions + this.w.layout.translateY + legendHeight + offY, ) if (this.w.dom.elLegendForeign) { this.w.dom.elLegendForeign.setAttribute('height', String(newHeight)) } if (w.config.chart.height && String(w.config.chart.height).includes('%')) return this.w.dom.elWrap.style.height = `${newHeight}px` Graphics.setAttrs(this.w.dom.Paper.node, { height: newHeight }) if (Environment.isBrowser()) { this.w.dom.Paper.node.parentNode.parentNode.style.minHeight = `${newHeight}px` } } coreCalculations() { new Range(this.w).init() } resetGlobals() { const resetxyValues = () => this.w.config.series.map(() => []) const globalObj = new Globals() const { globals: gl } = this.w const parsingFlags = { dataWasParsed: this.w.axisFlags.dataWasParsed, originalSeries: gl.originalSeries, } globalObj.initGlobalVars(gl) gl.seriesXvalues = resetxyValues() gl.seriesYvalues = resetxyValues() if (parsingFlags.dataWasParsed) { this.w.axisFlags.dataWasParsed = parsingFlags.dataWasParsed gl.originalSeries = parsingFlags.originalSeries } } isMultipleY() { if (Array.isArray(this.w.config.yaxis) && this.w.config.yaxis.length > 1) { this.w.globals.isMultipleYAxis = true return true } return false } xySettings() { const { w } = this let xyRatios = null if (w.globals.axisCharts) { if (w.config.xaxis.crosshairs.position === 'back') { new Crosshairs(this.w).drawXCrosshairs() } if (w.config.yaxis[0].crosshairs.position === 'back') { new Crosshairs(this.w).drawYCrosshairs() } if ( w.config.xaxis.type === 'datetime' && w.config.xaxis.labels.formatter === undefined ) { this.ctx.timeScale = new TimeScale(this.w, this.ctx) let formattedTimeScale = [] if ( isFinite(w.globals.minX) && isFinite(w.globals.maxX) && !w.globals.isBarHorizontal ) { formattedTimeScale = this.ctx.timeScale.calculateTimeScaleTicks( w.globals.minX, w.globals.maxX, ) } else if (w.globals.isBarHorizontal) { formattedTimeScale = this.ctx.timeScale.calculateTimeScaleTicks( w.globals.minY, w.globals.maxY, ) } this.ctx.timeScale.recalcDimensionsBasedOnFormat(formattedTimeScale) } const coreUtils = new CoreUtils(this.w) xyRatios = coreUtils.getCalculatedRatios() } return xyRatios } /** * @param {any} targetChart */ updateSourceChart(targetChart) { this.ctx.w.interact.selection = undefined this.ctx.updateHelpers._updateOptions( { chart: { selection: { xaxis: { min: targetChart.w.globals.minX, max: targetChart.w.globals.maxX, }, }, }, }, false, false, ) } setupBrushHandler() { const { ctx, w } = this if (!w.config.chart.brush.enabled) return if (typeof w.config.chart.events.selection !== 'function') { const targets = Array.isArray(w.config.chart.brush.targets) ? w.config.chart.brush.targets : [w.config.chart.brush.target] targets.forEach((/** @type {any} */ target) => { const targetChart = /** @type {any} */ (ctx.constructor).getChartByID( target, ) targetChart.w.globals.brushSource = this.ctx if (typeof targetChart.w.config.chart.events.zoomed !== 'function') { targetChart.w.config.chart.events.zoomed = () => this.updateSourceChart(targetChart) } if (typeof targetChart.w.config.chart.events.scrolled !== 'function') { targetChart.w.config.chart.events.scrolled = () => /** * @param {any} chart * @param {Event} e */ this.updateSourceChart(targetChart) } }) w.config.chart.events.selection = ( /** @type {any} */ chart, /** @type {any} */ e, ) => { targets.forEach((/** @type {any} */ target) => { const targetChart = /** @type {any} */ (ctx.constructor).getChartByID( target, ) targetChart.ctx.updateHelpers._updateOptions( { xaxis: { min: e.xaxis.min, max: e.xaxis.max, }, }, false, false, false, false, ) }) } } } getAccessibleChartLabel() { const w = this.w const cnf = w.config // Build descriptive label from available metadata let label = '' if (cnf.chart.accessibility && cnf.chart.accessibility.description) { label = cnf.chart.accessibility.description } else if (cnf.title.text) { const chartType = cnf.chart.type label = `${cnf.title.text}. ${chartType} chart` if (cnf.subtitle.text) { label += `. ${cnf.subtitle.text}` } } else { const chartType = cnf.chart.type // Use config.series if globals.series is not yet populated const seriesCount = w.seriesData.series.length || (cnf.series ? cnf.series.length : 0) label = `${chartType} chart with ${seriesCount} data series` } return label } }