apexcharts
Version:
A JavaScript Chart Library
529 lines (460 loc) • 15.1 kB
JavaScript
// @ts-check
import Utilities from '../../utils/Utils'
import Graphics from '../Graphics'
/**
* ApexCharts Tooltip.Utils Class to support Tooltip functionality.
*
* @module Tooltip.Utils
**/
export default class Utils {
/**
* @param {import('./Tooltip').default} tooltipContext
*/
constructor(tooltipContext) {
this.w = tooltipContext.w
this.ttCtx = tooltipContext
}
/**
** When hovering over series, you need to capture which series is being hovered on.
** This function will return both capturedseries index as well as inner index of that series
* @memberof Utils
* @param {{ hoverArea: any, elGrid: any, clientX: any, clientY: any, context?: any }} opts
*/
getNearestValues({ hoverArea, elGrid, clientX, clientY }) {
const w = this.w
const seriesBound = elGrid.getBoundingClientRect()
const hoverWidth = seriesBound.width
const hoverHeight = seriesBound.height
let xDivisor = hoverWidth / (w.globals.dataPoints - 1)
const yDivisor = hoverHeight / w.globals.dataPoints
const hasBars = this.hasBars()
if (
(w.globals.comboCharts || hasBars) &&
!w.config.xaxis.convertedCatToNumeric
) {
xDivisor = hoverWidth / w.globals.dataPoints
}
const hoverX = clientX - seriesBound.left - w.globals.barPadForNumericAxis
const hoverY = clientY - seriesBound.top
const notInRect =
hoverX < 0 || hoverY < 0 || hoverX > hoverWidth || hoverY > hoverHeight
if (notInRect) {
hoverArea.classList.remove('hovering-zoom')
hoverArea.classList.remove('hovering-pan')
} else {
if (w.interact.zoomEnabled) {
hoverArea.classList.remove('hovering-pan')
hoverArea.classList.add('hovering-zoom')
} else if (w.interact.panEnabled) {
hoverArea.classList.remove('hovering-zoom')
hoverArea.classList.add('hovering-pan')
}
}
let j = Math.round(hoverX / xDivisor)
const jHorz = Math.floor(hoverY / yDivisor)
if (hasBars && !w.config.xaxis.convertedCatToNumeric) {
j = Math.ceil(hoverX / xDivisor)
j = j - 1
}
let capturedSeries = null
let closest = null
/**
* @param {number[]} seriesXVal
*/
let seriesXValArr = w.globals.seriesXvalues.map(
(/** @type {any} */ seriesXVal) => {
/**
* @param {number} s
*/
return seriesXVal.filter((/** @type {any} */ s) =>
Utilities.isNumber(s),
)
},
)
/**
* @param {number[]} seriesYVal
*/
const seriesYValArr = w.globals.seriesYvalues.map(
(/** @type {any} */ seriesYVal) => {
/**
* @param {number} s
*/
return seriesYVal.filter((/** @type {any} */ s) =>
Utilities.isNumber(s),
)
},
)
// if X axis type is not category and tooltip is not shared, then we need to find the cursor position and get the nearest value
if (w.axisFlags.isXNumeric) {
// Change origin of cursor position so that we can compute the relative nearest point to the cursor on our chart
// we only need to scale because all points are relative to the bounds.left and bounds.top => origin is virtually (0, 0)
const chartGridEl = this.ttCtx.getElGrid()
if (!chartGridEl) return { hoverX, hoverY }
const chartGridElBoundingRect = chartGridEl.getBoundingClientRect()
const transformedHoverX =
hoverX * (chartGridElBoundingRect.width / hoverWidth)
const transformedHoverY =
hoverY * (chartGridElBoundingRect.height / hoverHeight)
closest = this.closestInMultiArray(
transformedHoverX,
transformedHoverY,
seriesXValArr,
seriesYValArr,
)
capturedSeries = closest.index
j = closest.j ?? 0
if (capturedSeries !== null && w.globals.hasNullValues) {
// initial push, it should be a little smaller than the 1st val
seriesXValArr = w.globals.seriesXvalues[capturedSeries]
closest = this.closestInArray(transformedHoverX, seriesXValArr)
j = closest.j ?? 0
}
}
w.interact.capturedSeriesIndex =
capturedSeries === null ? -1 : capturedSeries
if (!j || j < 1) j = 0
if (w.globals.isBarHorizontal) {
w.interact.capturedDataPointIndex = jHorz
} else {
w.interact.capturedDataPointIndex = j
}
return {
capturedSeries,
j: w.globals.isBarHorizontal ? jHorz : j,
hoverX,
hoverY,
}
}
/**
* @param {any[]} Xarrays
*/
getFirstActiveXArray(Xarrays) {
const w = this.w
let activeIndex = 0
/**
* @param {number[]} xarr
* @param {number} index
*/
const firstActiveSeriesIndex = Xarrays.map(
(/** @type {any} */ xarr, /** @type {any} */ index) => {
return xarr.length > 0 ? index : -1
},
)
for (let a = 0; a < firstActiveSeriesIndex.length; a++) {
if (
firstActiveSeriesIndex[a] !== -1 &&
w.globals.collapsedSeriesIndices.indexOf(a) === -1 &&
w.globals.ancillaryCollapsedSeriesIndices.indexOf(a) === -1
) {
activeIndex = firstActiveSeriesIndex[a]
break
}
}
return activeIndex
}
/**
* @param {number} hoverX
* @param {number} hoverY
* @param {any[]} Xarrays
* @param {any[]} Yarrays
*/
closestInMultiArray(hoverX, hoverY, Xarrays, Yarrays) {
const w = this.w
// Determine which series are active (not collapsed)
/**
* @param {number} seriesIndex
*/
const isActiveSeries = (seriesIndex) => {
return (
w.globals.collapsedSeriesIndices.indexOf(seriesIndex) === -1 &&
w.globals.ancillaryCollapsedSeriesIndices.indexOf(seriesIndex) === -1
)
}
// For pure line/area charts, measure distance to the line *segments*
// between consecutive points instead of just to the markers. Without
// this, hovering halfway between two markers picks whichever series's
// marker happens to be nearest to that empty space — often the wrong
// line. Only safe for non-combo line/area; bars/scatter/etc. fall
// through to the marker-distance path below.
const chartType = w.config.chart.type
const useSegmentDistance =
!w.globals.comboCharts &&
(chartType === 'line' || chartType === 'area')
let closestDist = Infinity
let closestSeriesIndex = null
let closestPointIndex = null
// For shared tooltips on bar charts with aligned x-axes, Y distance
// would arbitrarily pick whichever bar segment sits nearest the
// cursor vertically; ignore it (preserves the zoomed-bar fix from
// #3439). For line/area charts we need Y so click events report the
// actually-hovered series instead of always returning series 0.
const ignoreY =
w.config.tooltip.shared &&
w.globals.allSeriesHasEqualX &&
this.hasBars()
// Iterate through all series and points to find the closest (x,y) to (hoverX, hoverY)
for (let i = 0; i < Xarrays.length; i++) {
if (!isActiveSeries(i)) {
continue
}
const xArr = Xarrays[i]
const yArr = Yarrays[i]
const len = Math.min(xArr.length, yArr.length)
if (useSegmentDistance && len >= 2) {
for (let j = 0; j < len - 1; j++) {
const seg = this._distanceToSegment(
hoverX,
hoverY,
xArr[j],
yArr[j],
xArr[j + 1],
yArr[j + 1],
)
if (seg.dist < closestDist) {
closestDist = seg.dist
closestSeriesIndex = i
// j is used downstream for tooltip content — pick the endpoint
// the projection landed closer to so the value shown matches
// what the user is pointing at.
closestPointIndex = seg.t < 0.5 ? j : j + 1
}
}
continue
}
for (let j = 0; j < len; j++) {
const xVal = xArr[j]
const distX = hoverX - xVal
let dist = Math.sqrt(distX * distX)
if (!ignoreY) {
const yVal = yArr[j]
const distY = hoverY - yVal
dist = Math.sqrt(distX * distX + distY * distY)
}
if (dist < closestDist) {
closestDist = dist
closestSeriesIndex = i
closestPointIndex = j
}
}
}
return {
index: closestSeriesIndex,
j: closestPointIndex,
}
}
/**
* Perpendicular distance from point (px, py) to the line segment
* (ax, ay) → (bx, by). Returns the distance plus the projection
* parameter t (0 = at A, 1 = at B, clamped) so callers know which
* endpoint the projection landed nearest.
* @param {number} px
* @param {number} py
* @param {number} ax
* @param {number} ay
* @param {number} bx
* @param {number} by
* @returns {{ dist: number, t: number }}
*/
_distanceToSegment(px, py, ax, ay, bx, by) {
const dx = bx - ax
const dy = by - ay
const lenSq = dx * dx + dy * dy
let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq
if (t < 0) t = 0
else if (t > 1) t = 1
const cx = ax + t * dx
const cy = ay + t * dy
const ex = px - cx
const ey = py - cy
return { dist: Math.sqrt(ex * ex + ey * ey), t }
}
/**
* @param {number} val
* @param {any[]} arr
*/
closestInArray(val, arr) {
const curr = arr[0]
let currIndex = null
let diff = Math.abs(val - curr)
for (let i = 0; i < arr.length; i++) {
const newdiff = Math.abs(val - arr[i])
if (newdiff < diff) {
diff = newdiff
currIndex = i
}
}
return {
j: currIndex,
}
}
/**
* When there are multiple series, it is possible to have different x values for each series.
* But it may be possible in those multiple series, that there is same x value for 2 or more
* series.
* @memberof Utils
* @param {number} j - the inner index of series (series[i][j])
* @return {boolean}
*/
isXoverlap(j) {
const w = this.w
const xSameForAllSeriesJArr = []
/**
* @param {number[]} s
*/
const seriesX = w.seriesData.seriesX.filter(
(/** @type {any} */ s) => typeof s[0] !== 'undefined',
)
if (seriesX.length > 0) {
for (let i = 0; i < seriesX.length - 1; i++) {
if (
typeof seriesX[i][j] !== 'undefined' &&
typeof seriesX[i + 1][j] !== 'undefined'
) {
if (seriesX[i][j] !== seriesX[i + 1][j]) {
xSameForAllSeriesJArr.push('unEqual')
}
}
}
}
if (xSameForAllSeriesJArr.length === 0) {
return true
}
return false
}
isInitialSeriesSameLen() {
let sameLen = true
const initialSeries =
/** @type {any[]} */ (this.w.globals.initialSeries)?.filter(
/**
* @param {Record<string, any>} s
* @param {number} i
*/
(s, i) => !this.w.globals.collapsedSeriesIndices?.includes(i),
) || []
for (let i = 0; i < initialSeries.length - 1; i++) {
if (!initialSeries[i]?.data || !initialSeries[i + 1]?.data) return true
if (initialSeries[i].data.length !== initialSeries[i + 1].data.length) {
sameLen = false
break
}
}
return sameLen
}
/**
* @param {any[]} allbars
*/
getBarsHeight(allbars) {
const bars = [...allbars]
const totalHeight = bars.reduce((acc, bar) => acc + bar.getBBox().height, 0)
return totalHeight
}
/**
* @param {number} capturedSeries
*/
getElMarkers(capturedSeries) {
// The selector .apexcharts-series-markers-wrap > * includes marker groups for which the
// .apexcharts-series-markers class is not added due to null values or discrete markers
if (typeof capturedSeries == 'number') {
return this.w.dom.baseEl.querySelectorAll(
`.apexcharts-series[data\\:realIndex='${capturedSeries}'] .apexcharts-series-markers-wrap > *`,
)
}
return this.w.dom.baseEl.querySelectorAll(
'.apexcharts-series-markers-wrap > *',
)
}
getAllMarkers(filterCollapsed = false) {
// first get all marker parents. This parent class contains series-index
// which helps to sort the markers as they are dynamic
let markersWraps = /** @type {any[]} */ ([
...this.w.dom.baseEl.querySelectorAll('.apexcharts-series-markers-wrap'),
])
if (filterCollapsed) {
/**
* @param {any} m
*/
markersWraps = markersWraps.filter((/** @type {any} */ m) => {
const realIndex = Number(m.getAttribute('data:realIndex'))
return this.w.globals.collapsedSeriesIndices.indexOf(realIndex) === -1
})
}
/**
* @param {any} a
* @param {any} b
*/
markersWraps.sort((/** @type {any} */ a, /** @type {any} */ b) => {
var indexA = Number(a.getAttribute('data:realIndex'))
var indexB = Number(b.getAttribute('data:realIndex'))
return indexB < indexA ? 1 : indexB > indexA ? -1 : 0
})
/** @type {any[]} */
const markers = []
/**
* @param {any} m
*/
markersWraps.forEach((/** @type {any} */ m) => {
markers.push(m.querySelector('.apexcharts-marker'))
})
return markers
}
/**
* @param {number} capturedSeries
*/
hasMarkers(capturedSeries) {
const markers = this.getElMarkers(capturedSeries)
return markers.length > 0
}
/**
* @param {any} point
* @param {number} size
*/
getPathFromPoint(point, size) {
const cx = Number(point.getAttribute('cx'))
const cy = Number(point.getAttribute('cy'))
const shape = point.getAttribute('shape')
return new Graphics(this.w).getMarkerPath(cx, cy, shape, size)
}
getElBars() {
return this.w.dom.baseEl.querySelectorAll(
'.apexcharts-bar-series, .apexcharts-candlestick-series, .apexcharts-boxPlot-series, .apexcharts-rangebar-series',
)
}
hasBars() {
const bars = this.getElBars()
return bars.length > 0
}
/**
* @param {number} index
*/
getHoverMarkerSize(index) {
const w = this.w
let hoverSize = w.config.markers.hover.size
if (hoverSize === undefined) {
hoverSize =
w.globals.markers.size[index] + w.config.markers.hover.sizeOffset
}
return hoverSize
}
/**
* @param {string} state
*/
toggleAllTooltipSeriesGroups(state) {
const w = this.w
const ttCtx = this.ttCtx
if (ttCtx.allTooltipSeriesGroups.length === 0) {
ttCtx.allTooltipSeriesGroups = w.dom.baseEl.querySelectorAll(
'.apexcharts-tooltip-series-group',
)
}
const allTooltipSeriesGroups = ttCtx.allTooltipSeriesGroups
for (let i = 0; i < allTooltipSeriesGroups.length; i++) {
if (state === 'enable') {
allTooltipSeriesGroups[i].classList.add('apexcharts-active')
allTooltipSeriesGroups[i].style.display = w.config.tooltip.items.display
} else {
allTooltipSeriesGroups[i].classList.remove('apexcharts-active')
allTooltipSeriesGroups[i].style.display = 'none'
}
}
}
}