apexcharts
Version:
A JavaScript Chart Library
535 lines (475 loc) • 18.1 kB
JavaScript
// @ts-check
import YAxis from '../axes/YAxis'
import Helpers from './Helpers'
import DimXAxis from './XAxis'
import DimYAxis from './YAxis'
import Grid from './Grid'
import { LINE_HEIGHT_RATIO } from '../../utils/Constants'
import {
BREADCRUMB_HEIGHT,
BREADCRUMB_HEIGHT_FULL,
breadcrumbConfig,
} from '../../charts/common/Breadcrumb'
/**
* ApexCharts Dimensions Class for calculating rects of all elements that are drawn and will be drawn.
*
* @module Dimensions
**/
export default class Dimensions {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx // needed: new XAxis(w, ctx) for xAxisLabelClick event callback
this.theme = ctx.theme
this.timeScale = ctx.timeScale
this.lgRect = /** @type {any} */ ({})
this.yAxisWidth = 0
this.yAxisWidthLeft = 0
this.yAxisWidthRight = 0
this.xAxisHeight = 0
this.isSparkline = this.w.config.chart.sparkline.enabled
this.dimHelpers = new Helpers(this)
this.dimYAxis = new DimYAxis(this)
this.dimXAxis = new DimXAxis(this)
this.dimGrid = new Grid(this)
this.lgWidthForSideLegends = 0
// A COPY, never the config object itself: the chart type folds its own
// insets into this (sparkline markers/stroke, the treemap breadcrumb band)
// and layout code reads them back, which must not leak into the user's
// config. Consumers outside Dimensions read w.layout.gridPad.
// Re-derived per run by plotCoords(), see there.
this.gridPad = { ...this.w.config.grid.padding }
this.xPadRight = 0
// Room `gridPadFortitleSubtitle` leaves between the title block and the
// plot; the breadcrumb band is sized against it. Re-derived every run.
this.titleBlockPad = 0
this.xPadLeft = 0
this.datalabelsCoords = { width: 0, height: 0 }
/** @type {number} */
this.xAxisWidth = 0
/** @type {any[]} */
this.timescaleLabels = []
}
/**
* @memberof Dimensions
**/
plotCoords() {
const w = this.w
const gl = w.globals
// `update()` reuses this instance (Destroy.clear keeps the core modules
// alive), so the pad has to start from the user's config every run. The
// sparkline insets below only ever raise it to a floor and so survived
// being stale, but the breadcrumb band ADDS, and compounded: each render
// pushed the plot another 22px down and took 22px off its height.
this.gridPad = { ...w.config.grid.padding }
this.lgRect = this.dimHelpers.getLegendsRect()
this.datalabelsCoords = { width: 0, height: 0 }
if (this.isSparkline) {
// largestSize covers both markers.size (incl. the array form) and
// markers.discrete, so it is the one gate for "markers can overhang".
if (this.w.globals.markers.largestSize > 0) {
Object.entries(this.gridPad).forEach(([k, v]) => {
this.gridPad[k] = Math.max(
v,
this.w.globals.markers.largestSize / 1.5,
)
})
}
const strokeInset = this.dimHelpers.getSparklineStrokeInset()
this.gridPad.top = Math.max(strokeInset.top, this.gridPad.top)
this.gridPad.bottom = Math.max(strokeInset.bottom, this.gridPad.bottom)
}
if (gl.axisCharts) {
// for line / area / scatter / column
this.setDimensionsForAxisCharts()
} else {
// for pie / donuts / circle
this.setDimensionsForNonAxisCharts()
}
this.dimGrid.gridPadFortitleSubtitle()
this.gridPadForBreadcrumb()
// Must run before gridWidth has xPadRight subtracted from it below.
this.dimGrid.gridPadForStackedTotalDataLabels()
// after calculating everything, apply padding set by user
w.layout.gridHeight =
w.layout.gridHeight - this.gridPad.top - this.gridPad.bottom
w.layout.gridWidth =
w.layout.gridWidth -
this.gridPad.left -
this.gridPad.right -
this.xPadRight -
this.xPadLeft
const barWidth = this.dimGrid.gridPadForColumnsInNumericAxis(
w.layout.gridWidth,
)
w.layout.gridWidth = w.layout.gridWidth - barWidth * 2
w.layout.translateX =
w.layout.translateX +
this.gridPad.left +
this.xPadLeft +
(barWidth > 0 ? barWidth : 0)
w.layout.translateY = w.layout.translateY + this.gridPad.top
// Return a snapshot of all computed layout state grouped by future w.layout slice destination.
// Phase 1: callers use named writer stubs (no-ops — mutations above already wrote to gl).
// Phase 2: writers will assign to typed slices instead of gl.*.
return {
// w.layout (future slice)
layout: {
gridHeight: w.layout.gridHeight,
gridWidth: w.layout.gridWidth,
translateX: w.layout.translateX,
translateY: w.layout.translateY,
translateXAxisX: w.layout.translateXAxisX,
translateXAxisY: w.layout.translateXAxisY,
rotateXLabels: w.layout.rotateXLabels,
xAxisHeight: w.layout.xAxisHeight,
xAxisLabelsHeight: w.layout.xAxisLabelsHeight,
xAxisGroupLabelsHeight: w.layout.xAxisGroupLabelsHeight,
xAxisLabelsWidth: w.layout.xAxisLabelsWidth,
yLabelsCoords: w.layout.yLabelsCoords,
yTitleCoords: w.layout.yTitleCoords,
gridPad: { ...this.gridPad },
},
}
}
/**
* Reserve a strip above the plot for a navigation breadcrumb.
*
* A treemap fills its plot edge to edge, so unlike a sunburst - whose rings
* leave the corners empty - an absolutely-positioned breadcrumb has nowhere
* to float without covering a tile. Giving it real space is the only way it
* never overlaps.
*
* Reserved whenever click-to-zoom is enabled, not only while zoomed in: the
* strip appears and disappears as the reader navigates, and sizing the plot
* around its presence would reflow every tile on each zoom.
*/
gridPadForBreadcrumb() {
const w = this.w
const isTreemap = w.config.chart.type === 'treemap'
if (isTreemap) {
const zoom = w.config.plotOptions?.treemap?.zoom
if (zoom && zoom.enabled) {
if (breadcrumbConfig(w, zoom.breadcrumb).show === false) return
this.gridPad.top += BREADCRUMB_HEIGHT + 4
return
}
// Falls through on purpose: a treemap can be navigated by the drilldown
// feature instead of its own zoom, and that renders the same strip into
// the same place, so it needs the same band.
}
// As an overlay with nothing reserved, the drilldown strip was pushed below
// the title and came to rest on the top gridline and the first y-axis label.
// A pie or donut keeps floating instead: its corners are empty, which is
// what the overlay was designed around.
//
// `ctx.drilldown` is the feature gate. The config block exists even when the
// feature was never imported, and then no breadcrumb is ever rendered.
if (!w.globals.axisCharts) return
if (!this.ctx.drilldown) return
if (!w.config.drilldown || !w.config.drilldown.enabled) return
if (breadcrumbConfig(w).show === false) return
// What the strip needs is its own height plus the overhang of the topmost
// y-axis tick label, which is CENTRED on the plot's top edge and so hangs
// above the grid: clearing the grid alone still leaves the strip resting on
// that label. A treemap draws no y-axis labels and needs none of it. The
// placer measures the real overhang; this only has to leave room for it.
const labelFs =
parseFloat(String(w.config.yaxis?.[0]?.labels?.style?.fontSize)) || 11
const yLabelOverhang = isTreemap
? 0
: Math.ceil((labelFs * LINE_HEIGHT_RATIO) / 2)
const needed = BREADCRUMB_HEIGHT_FULL + 1 + yLabelOverhang
// Only the shortfall. `gridPadFortitleSubtitle` has already left breathing
// room between the title block and the plot, and the strip lives in it, so
// reserving a whole band on top of that pushed the plot down twice as far as
// it had to and left the strip floating in the middle of the gap.
//
// That room is real above a cartesian plot, which sits below its axis
// chrome, and mostly absent above a treemap, which fills its box edge to
// edge: measured 22.5px against 12.5px on the same title block. So a treemap
// lends nothing and reserves the strip in full.
const alreadyFree = isTreemap ? 0 : this.titleBlockPad || 0
this.gridPad.top += Math.max(0, needed - alreadyFree)
}
setDimensionsForAxisCharts() {
const w = this.w
const gl = w.globals
const yaxisLabelCoords = this.dimYAxis.getyAxisLabelsCoords()
const yTitleCoords = this.dimYAxis.getyAxisTitleCoords()
if (gl.isSlopeChart) {
this.datalabelsCoords = this.dimHelpers.getDatalabelsRect()
}
w.layout.yLabelsCoords = []
w.layout.yTitleCoords = []
/**
* @param {ApexYAxis} yaxe
* @param {number} index
*/
w.config.yaxis.map((yaxe, index) => {
// store the labels and titles coords in global vars
w.layout.yLabelsCoords.push({
width: yaxisLabelCoords[index].width,
index,
})
w.layout.yTitleCoords.push(
/** @type {any} */ ({
width: yTitleCoords[index].width,
index,
}),
)
})
this.yAxisWidth = this.dimYAxis.getTotalYAxisWidth()
const xaxisLabelCoords = this.dimXAxis.getxAxisLabelsCoords()
const xaxisGroupLabelCoords = this.dimXAxis.getxAxisGroupLabelsCoords()
const xtitleCoords = this.dimXAxis.getxAxisTitleCoords()
this.conditionalChecksForAxisCoords(
xaxisLabelCoords,
xtitleCoords,
xaxisGroupLabelCoords,
)
w.layout.translateXAxisY = w.layout.rotateXLabels
? this.xAxisHeight / 8
: -4
w.layout.translateXAxisX =
w.layout.rotateXLabels &&
w.axisFlags.isXNumeric &&
w.config.xaxis.labels.rotate <= -45
? -this.xAxisWidth / 4
: 0
if (w.globals.isBarHorizontal) {
w.layout.rotateXLabels = false
w.layout.translateXAxisY =
-1 * ((parseInt(w.config.xaxis.labels.style.fontSize, 10) || 12) / 1.5)
}
w.layout.translateXAxisY =
w.layout.translateXAxisY + w.config.xaxis.labels.offsetY
w.layout.translateXAxisX =
w.layout.translateXAxisX + w.config.xaxis.labels.offsetX
let yAxisWidth = this.yAxisWidth
let xAxisHeight = this.xAxisHeight
w.layout.xAxisLabelsHeight = this.xAxisHeight - xtitleCoords.height
w.layout.xAxisGroupLabelsHeight =
w.layout.xAxisLabelsHeight - xaxisLabelCoords.height
w.layout.xAxisLabelsWidth = this.xAxisWidth
w.layout.xAxisHeight = this.xAxisHeight
let translateY = 10
if (w.config.chart.type === 'radar' || this.isSparkline) {
yAxisWidth = 0
xAxisHeight = 0
}
if (this.isSparkline) {
this.lgRect = {
height: 0,
width: 0,
}
}
if (this.isSparkline || w.config.chart.type === 'treemap') {
yAxisWidth = 0
xAxisHeight = 0
translateY = 0
}
if (!this.isSparkline && w.config.chart.type !== 'treemap') {
this.dimXAxis.additionalPaddingXLabels(xaxisLabelCoords)
}
const legendTopBottom = () => {
w.layout.translateX = yAxisWidth + this.datalabelsCoords.width
w.layout.gridHeight =
gl.svgHeight -
this.lgRect.height -
xAxisHeight -
(!this.isSparkline && w.config.chart.type !== 'treemap'
? w.layout.rotateXLabels
? 10
: 15
: 0)
w.layout.gridWidth =
gl.svgWidth - yAxisWidth - this.datalabelsCoords.width * 2
}
if (w.config.xaxis.position === 'top')
translateY = w.layout.xAxisHeight - w.config.xaxis.axisTicks.height - 5
switch (w.config.legend.position) {
case 'bottom':
w.layout.translateY = translateY
legendTopBottom()
break
case 'top':
w.layout.translateY = this.lgRect.height + translateY
legendTopBottom()
break
case 'left':
w.layout.translateY = translateY
w.layout.translateX =
this.lgRect.width + yAxisWidth + this.datalabelsCoords.width
w.layout.gridHeight = gl.svgHeight - xAxisHeight - 12
w.layout.gridWidth =
gl.svgWidth -
this.lgRect.width -
yAxisWidth -
this.datalabelsCoords.width * 2
break
case 'right':
w.layout.translateY = translateY
w.layout.translateX = yAxisWidth + this.datalabelsCoords.width
w.layout.gridHeight = gl.svgHeight - xAxisHeight - 12
w.layout.gridWidth =
gl.svgWidth -
this.lgRect.width -
yAxisWidth -
this.datalabelsCoords.width * 2 -
5
break
default:
throw new Error('Legend position not supported')
}
this.dimGrid.setGridXPosForDualYAxis(yTitleCoords, yaxisLabelCoords)
// after drawing everything, set the Y axis positions
const objyAxis = new YAxis(this.w, {
theme: this.theme,
timeScale: this.timeScale,
})
objyAxis.setYAxisXPosition(yaxisLabelCoords, yTitleCoords)
}
setDimensionsForNonAxisCharts() {
const w = this.w
const gl = w.globals
const cnf = w.config
let xPad = 0
if (w.config.legend.show && !w.config.legend.floating) {
xPad = 20
}
// Unit (dot-cluster) charts fill the whole plot area - a row of clusters or
// one packed blob - rather than the square a pie/radialBar needs. Reserve
// legend space on the correct edge and take the rest.
if (cnf.chart.type === 'unit') {
const legendVisible = cnf.legend.show && !cnf.legend.floating
const pos = cnf.legend.position
let top = 0
let side = 0
if (legendVisible) {
if (pos === 'bottom' || pos === 'top') {
top = this.lgRect.height
} else {
side = this.lgRect.width + xPad
}
}
w.layout.gridWidth = gl.svgWidth - side
w.layout.gridHeight = gl.svgHeight - top
w.layout.translateX = pos === 'left' ? side : 0
w.layout.translateY = pos === 'top' ? top : 0
return
}
// Sunburst uses the same centred-square layout as pie, but reads its own
// offsets from plotOptions.sunburst.
const type =
cnf.chart.type === 'sunburst'
? 'sunburst'
: cnf.chart.type === 'pie' ||
cnf.chart.type === 'polarArea' ||
cnf.chart.type === 'donut'
? 'pie'
: 'radialBar'
const offY = cnf.plotOptions[type].offsetY
const offX = cnf.plotOptions[type].offsetX
if (!cnf.legend.show || cnf.legend.floating) {
w.layout.gridHeight = gl.svgHeight
const maxWidth = w.dom.elWrap.getBoundingClientRect().width
w.layout.gridWidth = Math.min(maxWidth, w.layout.gridHeight)
w.layout.translateY = offY
w.layout.translateX = offX + (gl.svgWidth - w.layout.gridWidth) / 2
return
}
switch (cnf.legend.position) {
case 'bottom':
w.layout.gridHeight = gl.svgHeight - this.lgRect.height
w.layout.gridWidth = gl.svgWidth
w.layout.translateY = offY - 10
w.layout.translateX = offX + (gl.svgWidth - w.layout.gridWidth) / 2
break
case 'top':
w.layout.gridHeight = gl.svgHeight - this.lgRect.height
w.layout.gridWidth = gl.svgWidth
w.layout.translateY = this.lgRect.height + offY + 10
w.layout.translateX = offX + (gl.svgWidth - w.layout.gridWidth) / 2
break
case 'left':
w.layout.gridWidth = gl.svgWidth - this.lgRect.width - xPad
w.layout.gridHeight =
cnf.chart.height !== 'auto' ? gl.svgHeight : w.layout.gridWidth
w.layout.translateY = offY
w.layout.translateX = offX + this.lgRect.width + xPad
break
case 'right':
w.layout.gridWidth = gl.svgWidth - this.lgRect.width - xPad - 5
w.layout.gridHeight =
cnf.chart.height !== 'auto' ? gl.svgHeight : w.layout.gridWidth
w.layout.translateY = offY
w.layout.translateX = offX + 10
break
default:
throw new Error('Legend position not supported')
}
}
/**
* @param {any} xaxisLabelCoords
* @param {any} xtitleCoords
* @param {any} xaxisGroupLabelCoords
*/
conditionalChecksForAxisCoords(
xaxisLabelCoords,
xtitleCoords,
xaxisGroupLabelCoords,
) {
const w = this.w
const xAxisNum = w.labelData.hasXaxisGroups ? 2 : 1
const baseXAxisHeight =
xaxisGroupLabelCoords.height +
xaxisLabelCoords.height +
xtitleCoords.height
const xAxisHeightMultiplicate = w.axisFlags.isMultiLineX
? 1.2
: LINE_HEIGHT_RATIO
const rotatedXAxisOffset = w.layout.rotateXLabels ? 22 : 10
const rotatedXAxisLegendOffset =
w.layout.rotateXLabels && w.config.legend.position === 'bottom'
const additionalOffset = rotatedXAxisLegendOffset ? 10 : 0
this.xAxisHeight =
baseXAxisHeight * xAxisHeightMultiplicate +
xAxisNum * rotatedXAxisOffset +
additionalOffset
this.xAxisWidth = xaxisLabelCoords.width
if (
this.xAxisHeight - xtitleCoords.height >
w.config.xaxis.labels.maxHeight
) {
this.xAxisHeight = w.config.xaxis.labels.maxHeight
}
if (
w.config.xaxis.labels.minHeight &&
this.xAxisHeight < w.config.xaxis.labels.minHeight
) {
this.xAxisHeight = w.config.xaxis.labels.minHeight
}
if (w.config.xaxis.floating) {
this.xAxisHeight = 0
}
let minYAxisWidth = 0
let maxYAxisWidth = 0
/**
* @param {number} y
*/
w.config.yaxis.forEach((y) => {
minYAxisWidth += y.labels.minWidth
maxYAxisWidth += y.labels.maxWidth
})
if (this.yAxisWidth < minYAxisWidth) {
this.yAxisWidth = minYAxisWidth
}
if (this.yAxisWidth > maxYAxisWidth) {
this.yAxisWidth = maxYAxisWidth
}
}
}