apexcharts
Version:
A JavaScript Chart Library
643 lines (566 loc) • 20.2 kB
JavaScript
// @ts-check
import CoreUtils from '../modules/CoreUtils'
import Bar from './Bar'
import Fill from '../modules/Fill'
import Graphics from '../modules/Graphics'
import Series from '../modules/Series'
import Utils from '../utils/Utils'
import { buildJitterGroups, renderJitter } from './common/Jitter'
/**
* ApexCharts BoxCandleStick Class responsible for drawing both Stacked Columns and Bars.
*
* @module BoxCandleStick
**/
class BoxCandleStick extends Bar {
/**
* @param {any[]} series
* @param {string} ctype
* @param {number} seriesIndex
*/
// @ts-ignore -- BoxCandleStick.draw has an extra ctype param compared to Bar.draw
draw(series, ctype, seriesIndex) {
const w = this.w
const graphics = new Graphics(this.w)
const type = w.globals.comboCharts ? ctype : w.config.chart.type
const fill = new Fill(this.w)
this.candlestickOptions = this.w.config.plotOptions.candlestick
this.boxOptions = this.w.config.plotOptions.boxPlot
this.isHorizontal = w.config.plotOptions.bar.horizontal
this.isOHLC =
this.candlestickOptions && this.candlestickOptions.type === 'ohlc'
this.coreUtils = new CoreUtils(this.w)
series = this.coreUtils.getLogSeries(series)
this.series = series
this.yRatio = this.coreUtils.getLogYRatios(this.yRatio)
this.barHelpers.initVariables(series)
const ret = graphics.group({
class: `apexcharts-${type}-series apexcharts-plot-series`,
})
for (let i = 0; i < series.length; i++) {
this.isBoxPlot =
w.config.chart.type === 'boxPlot' ||
/** @type {Record<string,any>} */ (w.config.series[i]).type === 'boxPlot'
/** @type {any} */
let x
/** @type {any} */
let y
const yArrj = [] // hold y values of current iterating series
const xArrj = [] // hold x values of current iterating series
const realIndex = w.globals.comboCharts
? /** @type {any} */ (seriesIndex)[i]
: i
const { columnGroupIndex } = this.barHelpers.getGroupIndex(realIndex)
const elSeries = graphics.group({
class: `apexcharts-series`,
seriesName: Utils.escapeString(w.seriesData.seriesNames[realIndex]),
rel: i + 1,
'data:realIndex': realIndex,
})
Series.addCollapsedClassToSeries(this.w, elSeries, realIndex)
if (series[i].length > 0) {
this.visibleI = this.visibleI + 1
}
let translationsIndex = 0
if (this.yRatio.length > 1) {
this.yaxisIndex = /** @type {any} */ (
w.globals.seriesYAxisReverseMap[realIndex]
)[0]
translationsIndex = realIndex
}
const initPositions = this.barHelpers.initialPositions(realIndex)
const {
y: initY,
barHeight,
yDivision, // yDivision is the GRIDHEIGHT divided by number of datapoints (bars)
zeroW, // zeroW is the baseline where 0 meets x axis
x: initX,
barWidth,
xDivision, // xDivision is the GRIDWIDTH divided by number of datapoints (columns)
zeroH, // zeroH is the baseline where 0 meets y axis
} = initPositions
y = initY
x = initX
xArrj.push(x + (barWidth ?? 0) / 2)
const elDataLabelsWrap = graphics.group({
class: 'apexcharts-datalabels',
'data:realIndex': realIndex,
})
const elGoalsMarkers = graphics.group({
class: 'apexcharts-bar-goals-markers',
})
// Optional jitter (raw observations supplied via `points`) per box —
// collected here, then rendered as one delayed-reveal overlay after all
// boxes. boxPlot only; candlesticks have no raw-observation channel.
const boxPointsOpts = this.isBoxPlot ? this.boxOptions.points : null
/** @type {{groups:{fill:string|null,d:string}[], j:number}[]} */
const pointsByCat = []
// Viewport culling (vertical candlestick / OHLC / boxPlot): for bars whose
// pixel-x falls outside the plot area (+ a one-bar buffer) drawVerticalBoxPaths
// returns before building the (costly) path string + pathFrom, and we skip
// DOM creation here. Pixel-space testing means this is automatic on zoom/pan
// (off-screen bars get a barXPosition < 0 or > gridWidth) and a complete no-op
// when the whole series fits. drawVerticalBoxPaths still computes each bar's
// x/y positions before the early-out, so the seriesXvalues/seriesYvalues
// arrays below stay filled for every j and tooltip/crosshair index-mapping
// stays correct for the culled bars.
const gridW = w.layout.gridWidth
const cullBuffer = barWidth ?? 0
for (let j = 0; j < w.globals.dataPoints; j++) {
const strokeWidth = this.barHelpers.getStrokeWidth(i, j, realIndex)
let paths = /** @type {any} */ (null)
const pathsParams = {
indexes: {
i,
j,
realIndex,
translationsIndex,
},
x,
y,
strokeWidth,
elSeries,
}
if (this.isHorizontal) {
paths = this.drawHorizontalBoxPaths({
...pathsParams,
yDivision,
barHeight,
zeroW,
})
} else {
paths = this.drawVerticalBoxPaths({
...pathsParams,
xDivision,
barWidth,
zeroH,
cullBounds: { lo: -cullBuffer, hi: gridW + cullBuffer },
})
}
y = paths.y
x = paths.x
// push current X / Y for every bar — even culled ones — so downstream
// tooltip/crosshair index-mapping (seriesXvalues/seriesYvalues) is dense.
if (j > 0) {
xArrj.push(x + (barWidth ?? 0) / 2)
}
yArrj.push(y)
// drawVerticalBoxPaths returns culled:true for off-screen bars (it
// skipped the path-string build). Horizontal bars are never culled.
if (paths.culled) {
continue
}
const barGoalLine = this.barHelpers.drawGoalLine({
barXPosition: paths.barXPosition,
barYPosition: paths.barYPosition,
goalX: paths.goalX,
goalY: paths.goalY,
barHeight,
barWidth,
})
if (barGoalLine) {
elGoalsMarkers.add(barGoalLine)
}
/**
* @param {string} pathTo
* @param {number} pi
*/
paths.pathTo.forEach(
(/** @type {any} */ pathTo, /** @type {any} */ pi) => {
const lineFill =
!this.isBoxPlot && this.candlestickOptions.wick.useFillColor
? paths.color[pi]
: w.globals.stroke.colors[i]
const pathFill = fill.fillPath({
seriesNumber: realIndex,
dataPointIndex: j,
color: paths.color[pi],
value: series[i][j],
})
this.renderSeries({
realIndex,
pathFill,
lineFill,
j,
i,
pathFrom: paths.pathFrom,
pathTo,
strokeWidth,
elSeries,
x,
y,
series,
columnGroupIndex,
barHeight,
barWidth,
elDataLabelsWrap,
elGoalsMarkers,
visibleSeries: this.visibleI,
type: w.config.chart.type,
})
},
)
// Build jitter sub-paths for this box (scattered within the box width).
if (boxPointsOpts && boxPointsOpts.show !== false) {
const pts = w.candleData.seriesBoxPoints[realIndex]?.[j]
if (pts && pts.length) {
/** @param {number} v */
const logVal = (v) =>
/** @type {any} */ (this.coreUtils).getLogValAtSeriesIndex(
v,
realIndex,
)
let center, halfExtent, alongFn
if (this.isHorizontal) {
const yRatio = this.invertedYRatio
const bh = barHeight ?? 0
const z = zeroW ?? 0
center = paths.barYPosition + bh / 2
halfExtent = bh / 2
alongFn = (/** @type {number} */ v) => z + logVal(v) / yRatio
} else {
const yRatio = this.yRatio[translationsIndex]
const bw = barWidth ?? 0
const z = zeroH ?? 0
center = paths.barXPosition + bw / 2
halfExtent = bw / 2
alongFn = (/** @type {number} */ v) => z - logVal(v) / yRatio
}
const groups = buildJitterGroups({
w,
points: pts,
seedA: realIndex,
seedB: j,
center,
halfExtent,
alongFn,
isHorizontal: this.isHorizontal,
options: boxPointsOpts,
})
if (groups.length) pointsByCat.push({ groups, j })
}
}
}
// Jitter overlay (shared module): one packed path per box, revealed
// gradually after the box paths animate.
if (boxPointsOpts) {
renderJitter({
graphics,
w,
elSeries,
pointsByCat,
options: boxPointsOpts,
distributed: w.config.plotOptions.bar.distributed,
realIndex,
wrapClass: 'apexcharts-boxPlot-points-wrap',
pointClass: 'apexcharts-boxPlot-points',
})
}
// push all x val arrays into main xArr
w.globals.seriesXvalues[realIndex] = xArrj
w.globals.seriesYvalues[realIndex] = yArrj
ret.add(elSeries)
}
return ret
}
/** @param {{indexes: any, x: any, xDivision: any, barWidth: any, zeroH: any, strokeWidth: any, cullBounds?: {lo: number, hi: number}|null}} opts */
drawVerticalBoxPaths({
indexes,
x,
xDivision,
barWidth,
zeroH,
strokeWidth,
cullBounds = null,
}) {
const w = this.w
const graphics = new Graphics(this.w)
const i = indexes.i
const j = indexes.j
const { colors: candleColors } = w.config.plotOptions.candlestick
const { colors: boxColors } = this.boxOptions
const realIndex = indexes.realIndex
/**
* @param {string} color
*/
const getColor = (color) =>
Array.isArray(color) ? color[realIndex] : color
const colorPos = getColor(candleColors.upward)
const colorNeg = getColor(candleColors.downward)
const yRatio = this.yRatio[indexes.translationsIndex]
const ohlc = this.getOHLCValue(realIndex, j)
let l1 = zeroH
let l2 = zeroH
let color = ohlc.o < ohlc.c ? [colorPos] : [colorNeg]
if (this.isBoxPlot) {
color = [getColor(boxColors.lower), getColor(boxColors.upper)]
}
let y1 = Math.min(ohlc.o, ohlc.c)
let y2 = Math.max(ohlc.o, ohlc.c)
let m = ohlc.m
if (w.axisFlags.isXNumeric) {
x =
(w.seriesData.seriesX[realIndex][j] - w.globals.minX) / this.xRatio -
barWidth / 2
}
const barXPosition = x + barWidth * this.visibleI
if (
typeof /** @type {any} */ (this.series)[i]?.[j] === 'undefined' ||
/** @type {any} */ (this.series)[i]?.[j] === null
) {
y1 = zeroH
y2 = zeroH
} else {
y1 = zeroH - y1 / yRatio
y2 = zeroH - y2 / yRatio
l1 = zeroH - ohlc.h / yRatio
l2 = zeroH - ohlc.l / yRatio
m = zeroH - ohlc.m / yRatio
}
// Viewport cull: an off-screen bar skips the path-string build below (and
// pathFrom). Positions (barXPosition / y2 / advanced x) are already computed,
// so the caller still fills seriesXvalues/seriesYvalues densely — only the
// string concatenation + DOM work is avoided. x advances exactly as the
// normal return path would (category axis accumulates xDivision; numeric
// recomputes from seriesX next iteration so its returned x is moot).
if (
cullBounds &&
(barXPosition + barWidth < cullBounds.lo ||
barXPosition > cullBounds.hi)
) {
return {
pathTo: null,
pathFrom: null,
x: w.axisFlags.isXNumeric ? x : x + xDivision,
y: y2,
barXPosition,
color,
culled: true,
}
}
let pathTo
if (this.isOHLC) {
const centerX = barXPosition + barWidth / 2
const openY = zeroH - ohlc.o / yRatio
const closeY = zeroH - ohlc.c / yRatio
pathTo = [
graphics.move(centerX, l1) +
graphics.line(centerX, l2) +
graphics.move(centerX, openY) +
graphics.line(barXPosition, openY) +
graphics.move(centerX, closeY) +
graphics.line(barXPosition + barWidth, closeY),
]
} else if (this.isBoxPlot) {
pathTo = [
graphics.move(barXPosition, y1) +
graphics.line(barXPosition + barWidth / 2, y1) +
graphics.line(barXPosition + barWidth / 2, l1) +
graphics.line(barXPosition + barWidth / 4, l1) +
graphics.line(barXPosition + barWidth - barWidth / 4, l1) +
graphics.line(barXPosition + barWidth / 2, l1) +
graphics.line(barXPosition + barWidth / 2, y1) +
graphics.line(barXPosition + barWidth, y1) +
graphics.line(barXPosition + barWidth, m) +
graphics.line(barXPosition, m) +
graphics.line(barXPosition, y1 + strokeWidth / 2),
graphics.move(barXPosition, m) +
graphics.line(barXPosition + barWidth, m) +
graphics.line(barXPosition + barWidth, y2) +
graphics.line(barXPosition + barWidth / 2, y2) +
graphics.line(barXPosition + barWidth / 2, l2) +
graphics.line(barXPosition + barWidth - barWidth / 4, l2) +
graphics.line(barXPosition + barWidth / 4, l2) +
graphics.line(barXPosition + barWidth / 2, l2) +
graphics.line(barXPosition + barWidth / 2, y2) +
graphics.line(barXPosition, y2) +
graphics.line(barXPosition, m) +
'z',
]
} else {
// Regular candlestick
pathTo = [
graphics.move(barXPosition, y2) +
graphics.line(barXPosition + barWidth / 2, y2) +
graphics.line(barXPosition + barWidth / 2, l1) +
graphics.line(barXPosition + barWidth / 2, y2) +
graphics.line(barXPosition + barWidth, y2) +
graphics.line(barXPosition + barWidth, y1) +
graphics.line(barXPosition + barWidth / 2, y1) +
graphics.line(barXPosition + barWidth / 2, l2) +
graphics.line(barXPosition + barWidth / 2, y1) +
graphics.line(barXPosition, y1) +
graphics.line(barXPosition, y2 - strokeWidth / 2),
]
}
let pathFrom
if (w.globals.previousPaths.length > 0) {
// Update: survivor with matching command count → animate; else snap.
pathFrom = this.getPreviousPath(realIndex, j, pathTo[0])
} else {
// Initial mount: baseline collapsed at the bar's center.
pathFrom =
graphics.move(barXPosition + barWidth / 2, y1) +
graphics.move(barXPosition, y1)
}
if (!w.axisFlags.isXNumeric) {
x = x + xDivision
}
return {
pathTo,
pathFrom,
x,
y: y2,
goalY: this.barHelpers.getGoalValues(
'y',
/** @type {any} */ (null),
zeroH,
i,
j,
indexes.translationsIndex,
),
barXPosition,
color,
}
}
/** @param {{indexes: any, y: any, yDivision: any, barHeight: any, zeroW: any, strokeWidth: any}} opts */
drawHorizontalBoxPaths({
indexes,
y,
yDivision,
barHeight,
zeroW,
strokeWidth,
}) {
const w = this.w
const graphics = new Graphics(this.w)
const i = indexes.i
const j = indexes.j
const realIndex = indexes.realIndex
const { colors: candleColors } = w.config.plotOptions.candlestick
const { colors: boxColors } = this.boxOptions
/**
* @param {string} color
*/
const getColor = (color) =>
Array.isArray(color) ? color[realIndex] : color
const yRatio = this.invertedYRatio
const ohlc = this.getOHLCValue(realIndex, j)
let color =
ohlc.o < ohlc.c
? [getColor(candleColors.upward)]
: [getColor(candleColors.downward)]
if (this.isBoxPlot) {
color = [getColor(boxColors.lower), getColor(boxColors.upper)]
}
let l1 = zeroW
let l2 = zeroW
let x1 = Math.min(ohlc.o, ohlc.c)
let x2 = Math.max(ohlc.o, ohlc.c)
let m = ohlc.m
if (w.axisFlags.isXNumeric) {
y =
(w.seriesData.seriesX[realIndex][j] - w.globals.minX) /
this.invertedXRatio -
barHeight / 2
}
const barYPosition = y + barHeight * this.visibleI
if (
typeof /** @type {any} */ (this.series)[i]?.[j] === 'undefined' ||
/** @type {any} */ (this.series)[i]?.[j] === null
) {
x1 = zeroW
x2 = zeroW
} else {
x1 = zeroW + x1 / yRatio
x2 = zeroW + x2 / yRatio
l1 = zeroW + ohlc.h / yRatio
l2 = zeroW + ohlc.l / yRatio
m = zeroW + ohlc.m / yRatio
}
const pathTo = [
graphics.move(x1, barYPosition) +
graphics.line(x1, barYPosition + barHeight / 2) +
graphics.line(l1, barYPosition + barHeight / 2) +
graphics.line(l1, barYPosition + barHeight / 2 - barHeight / 4) +
graphics.line(l1, barYPosition + barHeight / 2 + barHeight / 4) +
graphics.line(l1, barYPosition + barHeight / 2) +
graphics.line(x1, barYPosition + barHeight / 2) +
graphics.line(x1, barYPosition + barHeight) +
graphics.line(m, barYPosition + barHeight) +
graphics.line(m, barYPosition) +
graphics.line(x1 + strokeWidth / 2, barYPosition),
graphics.move(m, barYPosition) +
graphics.line(m, barYPosition + barHeight) +
graphics.line(x2, barYPosition + barHeight) +
graphics.line(x2, barYPosition + barHeight / 2) +
graphics.line(l2, barYPosition + barHeight / 2) +
graphics.line(l2, barYPosition + barHeight - barHeight / 4) +
graphics.line(l2, barYPosition + barHeight / 4) +
graphics.line(l2, barYPosition + barHeight / 2) +
graphics.line(x2, barYPosition + barHeight / 2) +
graphics.line(x2, barYPosition) +
graphics.line(m, barYPosition) +
'z',
]
let pathFrom
if (w.globals.previousPaths.length > 0) {
// Update: survivor with matching command count → animate; else snap.
pathFrom = this.getPreviousPath(realIndex, j, pathTo[0])
} else {
// Initial mount: baseline collapsed at the bar's center.
pathFrom =
graphics.move(x1, barYPosition + barHeight / 2) +
graphics.move(x1, barYPosition)
}
if (!w.axisFlags.isXNumeric) {
y = y + yDivision
}
return {
pathTo,
pathFrom,
x: x2,
y,
goalX: this.barHelpers.getGoalValues(
'x',
zeroW,
/** @type {any} */ (null),
i,
j,
0,
),
barYPosition,
color,
}
}
/**
* @param {number} i
* @param {number} j
*/
getOHLCValue(i, j) {
const w = this.w
const coreUtils = this.coreUtils
/**
* @param {any[]} arr
*/
const getCandleVal = (arr) =>
arr[i] && arr[i][j] != null
? /** @type {any} */ (coreUtils).getLogValAtSeriesIndex(arr[i][j], i)
: 0
const h = getCandleVal(w.candleData.seriesCandleH)
const o = getCandleVal(w.candleData.seriesCandleO)
const m = getCandleVal(w.candleData.seriesCandleM)
const c = getCandleVal(w.candleData.seriesCandleC)
const l = getCandleVal(w.candleData.seriesCandleL)
// BoxPlot data arrives as [min, q1, median, q3, max] and is stored in
// H=min, O=q1, M=median, C=q3, L=max — remap to OHLC semantics:
// o=q1(O), h=min(H), m=median(M), l=q3(C), c=max(L)
return {
o: this.isBoxPlot ? h : o,
h: this.isBoxPlot ? o : h,
m,
l: this.isBoxPlot ? c : l,
c: this.isBoxPlot ? l : c,
}
}
}
export default BoxCandleStick