apexcharts
Version:
A JavaScript Chart Library
1,081 lines (934 loc) • 37.6 kB
JavaScript
// @ts-check
import Graphics from '../Graphics'
import Utils from '../../utils/Utils'
/**
* ApexCharts KeyboardNavigation
*
* Enables keyboard users to navigate between data points using arrow keys and
* trigger tooltips without a mouse. Plugs into the existing tooltip pipeline —
* no new rendering logic is introduced.
*
* Key bindings (active when the chart SVG has focus):
* ArrowRight / ArrowLeft — next / previous data point
* ArrowUp / ArrowDown — next / previous series (skips collapsed)
* Home — first data point in current series
* End — last data point in current series
* Enter / Space — fire markerClick event (same as mouse click)
* Escape — exit keyboard nav, return focus to SVG
*/
export default class KeyboardNavigation {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx // needed: ctx.events.addEventListener/removeEventListener
// Current navigation cursor
this.seriesIndex = 0
this.dataPointIndex = 0
this.active = false
// Previously focused SVG element (for removing the focus class)
this._focusedEl = null
// SVG.js wrapper of the currently hovered bar element, so we can call
// pathMouseLeave when navigating away or losing focus.
this._hoveredBarEl = null
// The scatter/bubble marker element that was enlarged by keyboard nav,
// so we can reset only that one element on the next navigation step
// (avoids calling resetPointsSize() on all markers which triggers spurious
// mouseout events on the whole marker set, causing resize jitter).
this._enlargedScatterMarker = null
// Bound handlers (stored so we can removeEventListener later)
this._onKeyDown = this._onKeyDown.bind(this)
this._onFocus = this._onFocus.bind(this)
this._onBlur = this._onBlur.bind(this)
this._onLegendClick = this._onLegendClick.bind(this)
}
// ─── Public API ───────────────────────────────────────────────────────────
/**
* Called after the chart and tooltip have been fully rendered.
* Attaches event listeners and makes the SVG keyboard-focusable.
*/
init() {
const w = this.w
const svgEl = w.dom.Paper.node
if (!svgEl) return
svgEl.setAttribute('tabindex', '0')
svgEl.addEventListener('focus', this._onFocus)
svgEl.addEventListener('blur', this._onBlur)
// Use a non-passive keydown listener directly on the SVG so that
// preventDefault() works (required to suppress page scroll on arrow keys).
// Events.js listens on the ancestor canvas div as passive:true, so it
// cannot call preventDefault — we handle navigation here instead.
svgEl.addEventListener('keydown', this._onKeyDown, { passive: false })
// When the user clicks a legend item (collapse/expand a series), hide the
// keyboard-nav tooltip so it doesn't remain stuck on screen while the chart
// re-renders with a different set of visible series.
this.ctx.events.addEventListener('legendClick', this._onLegendClick)
}
/**
* Removes all event listeners. Called from chart.destroy().
*/
destroy() {
const w = this.w
const svgEl = w.dom.Paper && w.dom.Paper.node
if (!svgEl) return
svgEl.removeEventListener('focus', this._onFocus)
svgEl.removeEventListener('blur', this._onBlur)
svgEl.removeEventListener('keydown', this._onKeyDown)
this.ctx.events.removeEventListener('legendClick', this._onLegendClick)
}
/**
* Called from Events.js keydown handler. Navigation keys are already handled
* by the direct SVG listener (which can call preventDefault). This entry
* point is intentionally a no-op — Events.js still fires the public keyDown
* callback and fireEvent('keydown') independently.
* @param {Event} _e
*/
handleKey(_e) {
// No-op: navigation is handled by the non-passive SVG keydown listener
// added in init(). Keeping this method so Events.js doesn't need changes.
}
// ─── Focus / blur ─────────────────────────────────────────────────────────
_onFocus() {
if (!this._isNavEnabled()) return
this.active = true
// Clamp cursor to valid range in case series/data changed since last focus
this._clampCursor()
// If the chart is zoomed, snap the cursor to the first visible data point
// so the user doesn't start navigating outside the current viewport.
this._snapToVisibleRange()
this._showCurrentPoint()
}
_onBlur() {
this.active = false
this._hideFocus()
}
// Called when the user clicks a legend item (collapse/expand a series).
// Hide the keyboard-nav tooltip — the chart is about to re-render and the
// current position may no longer be valid.
_onLegendClick() {
if (!this.active) return
this.active = false
this._hideFocus()
}
// ─── Key handler ──────────────────────────────────────────────────────────
/**
* @param {KeyboardEvent} e
*/
_onKeyDown(e) {
if (!this._isNavEnabled() || !this.active) return
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
this._move(0, 1)
break
case 'ArrowLeft':
e.preventDefault()
this._move(0, -1)
break
case 'ArrowUp':
e.preventDefault()
this._move(-1, 0)
break
case 'ArrowDown':
e.preventDefault()
this._move(1, 0)
break
case 'Home':
e.preventDefault()
this.dataPointIndex = 0
this._skipNullForward()
this._showCurrentPoint()
break
case 'End':
e.preventDefault()
this.dataPointIndex = this._getDataPointCount(this.seriesIndex) - 1
this._skipNullBackward()
this._showCurrentPoint()
break
case 'Enter':
case ' ':
e.preventDefault()
this._fireClick()
break
case 'Escape':
e.preventDefault()
this.active = false
this._hideFocus()
break
default:
break
}
}
// ─── Navigation ───────────────────────────────────────────────────────────
/**
* @param {number} dSeries
* @param {number} dPoint
*/
_move(dSeries, dPoint) {
const w = this.w
const wrapAround =
w.config.chart.accessibility.keyboard.navigation.wrapAround
if (dSeries !== 0) {
// When tooltip.shared = true AND x values actually align across all
// series at the current index, the tooltip covers all series identically,
// so ↑/↓ series switching would show the same content — suppress it.
// For irregular time series the tooltip falls back to individual mode
// (isXoverlap returns false), so up/down navigation is meaningful there.
const ttCtx = this.w.globals.tooltip
if (ttCtx && ttCtx.tConfig && ttCtx.tConfig.shared) {
const j = this.dataPointIndex
const isActuallyShared =
ttCtx.tooltipUtil &&
ttCtx.tooltipUtil.isXoverlap(j) &&
ttCtx.tooltipUtil.isInitialSeriesSameLen()
if (isActuallyShared) return
}
// Move between series (↑/↓)
const total = this._getSeriesCount()
let si = this.seriesIndex + dSeries
// Skip collapsed series
let attempts = 0
while (attempts < total) {
if (si < 0) si = wrapAround ? total - 1 : 0
if (si >= total) si = wrapAround ? 0 : total - 1
if (!w.globals.collapsedSeriesIndices.includes(si)) break
si += dSeries
attempts++
}
this.seriesIndex = si
// Keep dataPointIndex within bounds of the new series
const dpCount = this._getDataPointCount(si)
if (this.dataPointIndex >= dpCount) {
this.dataPointIndex = dpCount - 1
}
}
if (dPoint !== 0) {
// Move between data points (←/→)
const dpCount = this._getDataPointCount(this.seriesIndex)
let di = this.dataPointIndex + dPoint
if (di < 0) di = wrapAround ? dpCount - 1 : 0
if (di >= dpCount) di = wrapAround ? 0 : dpCount - 1
this.dataPointIndex = di
// Skip null values
if (dPoint > 0) {
this._skipNullForward()
} else {
this._skipNullBackward()
}
// If the chart is zoomed, skip data points outside the visible range
if (!this._isDataPointVisible(this.seriesIndex, this.dataPointIndex)) {
this._snapToVisibleRangeInDirection(dPoint)
}
}
this._showCurrentPoint()
}
/** Advance dataPointIndex forward past any nulls */
_skipNullForward() {
const w = this.w
const si = this.seriesIndex
const dpCount = this._getDataPointCount(si)
let di = this.dataPointIndex
let attempts = 0
// Non-axis charts (pie, etc.) have flat numeric series — no nulls to skip
if (!Array.isArray(w.seriesData.series[si])) return
while (attempts < dpCount && w.seriesData.series[si][di] === null) {
di = (di + 1) % dpCount
attempts++
}
this.dataPointIndex = di
}
/** Retreat dataPointIndex backward past any nulls */
_skipNullBackward() {
const w = this.w
const si = this.seriesIndex
const dpCount = this._getDataPointCount(si)
let di = this.dataPointIndex
let attempts = 0
// Non-axis charts (pie, etc.) have flat numeric series — no nulls to skip
if (!Array.isArray(w.seriesData.series[si])) return
while (attempts < dpCount && w.seriesData.series[si][di] === null) {
di = (di - 1 + dpCount) % dpCount
attempts++
}
this.dataPointIndex = di
}
// ─── Display ──────────────────────────────────────────────────────────────
_showCurrentPoint() {
const { seriesIndex: i, dataPointIndex: j } = this
const w = this.w
const ttCtx = w.globals.tooltip
if (!ttCtx || !ttCtx.ttItems) return
// Keep globals consistent with the rest of the system
w.interact.capturedSeriesIndex = i
w.interact.capturedDataPointIndex = j
this._applyFocusClass(i, j)
this._showTooltip(i, j, /** @type {any} */ (ttCtx))
}
_hideFocus() {
const w = this.w
const ttCtx = /** @type {any} */ (w.globals.tooltip)
this._removeFocusClass()
this._leaveHoveredBar()
if (!ttCtx) return
// Reset markers
if (ttCtx.marker) {
ttCtx.marker.resetPointsSize()
}
this._enlargedScatterMarker = null
// Hide tooltip and crosshairs using the existing cleanup path
const tooltipEl = ttCtx.getElTooltip()
if (tooltipEl) {
tooltipEl.classList.remove('apexcharts-active')
if (
w.config.chart.accessibility.enabled &&
w.config.chart.accessibility.announcements.enabled
) {
tooltipEl.setAttribute('aria-hidden', 'true')
}
}
w.dom.baseEl.classList.remove('apexcharts-tooltip-active')
const xcrosshairs = ttCtx.getElXCrosshairs()
if (xcrosshairs) xcrosshairs.classList.remove('apexcharts-active')
}
// ─── Tooltip display per chart type ───────────────────────────────────────
/**
* @param {number} i
* @param {number} j
* @param {import('../tooltip/Tooltip').default} ttCtx
*/
_showTooltip(i, j, ttCtx) {
const w = this.w
const type = w.config.chart.type
// Make tooltip visible
const tooltipEl = ttCtx.getElTooltip()
if (!tooltipEl) return
// tooltipRect is normally set by seriesHoverByContext on every mouse event.
// When keyboard nav fires without any prior mouse interaction it is
// undefined, which makes every positioning helper produce NaN/0 coords.
// Populate it here from the cached dimensions so the tooltip appears at the
// correct position even on the very first keyboard interaction.
const cachedDims = ttCtx.getCachedDimensions()
ttCtx.tooltipRect = {
x: 0,
y: 0,
ttWidth: cachedDims.ttWidth || 0,
ttHeight: cachedDims.ttHeight || 0,
}
// Several Position methods (moveTooltip, moveDynamicPointsOnHover,
// moveStickyTooltipOverBars) branch on tooltip.followCursor and read
// ttCtx.e.clientX/Y when it is true. In keyboard nav there is no real
// mouse event, so we synthesise one whose clientX/Y equal the element's
// viewport centre. This makes followCursor charts position the tooltip
// at the data-point element instead of at 0,0 (or crashing on undefined).
// We restore the original ttCtx.e after positioning so that no downstream
// code is surprised.
this._setSyntheticEvent(i, j, ttCtx)
w.dom.baseEl.classList.add('apexcharts-tooltip-active')
tooltipEl.classList.add('apexcharts-active')
if (
w.config.chart.accessibility.enabled &&
w.config.chart.accessibility.announcements.enabled
) {
tooltipEl.removeAttribute('aria-hidden')
}
if (type === 'pie' || type === 'donut' || type === 'polarArea') {
this._showTooltipNonAxis(i, j, ttCtx, tooltipEl)
} else if (type === 'radialBar') {
this._showTooltipRadialBar(i, j, ttCtx, tooltipEl)
} else if (type === 'heatmap' || type === 'treemap') {
this._showTooltipHeatTree(i, j, ttCtx, tooltipEl, type)
} else if (
type === 'bar' ||
type === 'candlestick' ||
type === 'boxPlot' ||
type === 'rangeBar'
) {
this._showTooltipBar(i, j, ttCtx)
} else {
// line, area, scatter, bubble, radar, rangeArea
this._showTooltipAxisLine(i, j, ttCtx)
}
}
/**
* Set ttCtx.e to a synthetic mouse-event-like object whose clientX/Y point
* to the centre of the current data-point element. This ensures that any
* positioning helper that reads ttCtx.e (followCursor path in moveTooltip,
* moveStickyTooltipOverBars, moveDynamicPointsOnHover, etc.) gets valid
* coordinates rather than crashing on undefined.
*
* For chart types that don't have a concrete SVG element per data point
* (pie, radialBar) we fall back to the SVG centre.
* @param {number} i
* @param {number} j
* @param {import('../tooltip/Tooltip').default} ttCtx
*/
_setSyntheticEvent(i, j, ttCtx) {
const w = this.w
const type = w.config.chart.type
let clientX = 0
let clientY = 0
// Try to find the element and use its centre as the synthetic position
const el = this._getFocusableElement(i, j)
if (el) {
const rect = el.getBoundingClientRect()
clientX = rect.left + rect.width / 2
clientY = rect.top + rect.height / 2
} else if (
w.globals.pointsArray &&
w.globals.pointsArray[i] &&
w.globals.pointsArray[i][j]
) {
// Axis-line charts: derive from pointsArray pixel coords
const pt = w.globals.pointsArray[i][j]
const elGrid = ttCtx.getElGrid && ttCtx.getElGrid()
if (elGrid) {
const gridRect = elGrid.getBoundingClientRect()
clientX = gridRect.left + (pt[0] || 0)
clientY = gridRect.top + (pt[1] || 0)
}
} else {
// Fallback: SVG element centre
const svgEl = w.dom.Paper && w.dom.Paper.node
if (svgEl) {
const svgRect = svgEl.getBoundingClientRect()
clientX = svgRect.left + svgRect.width / 2
clientY = svgRect.top + svgRect.height / 2
}
}
// For line/area/rangeArea: pointsArray gives the most accurate position
if (
type === 'line' ||
type === 'area' ||
type === 'rangeArea' ||
type === 'scatter' ||
type === 'bubble' ||
type === 'radar'
) {
if (
w.globals.pointsArray &&
w.globals.pointsArray[i] &&
w.globals.pointsArray[i][j]
) {
const pt = w.globals.pointsArray[i][j]
const elGrid = ttCtx.getElGrid && ttCtx.getElGrid()
if (elGrid) {
const gridRect = elGrid.getBoundingClientRect()
clientX = gridRect.left + (pt[0] || 0)
clientY = gridRect.top + (pt[1] || 0)
}
}
}
ttCtx.e = { type: 'mousemove', clientX, clientY }
}
/**
* bar / column / candlestick / boxPlot / rangeBar
* @param {number} i
* @param {number} j
* @param {import('../tooltip/Tooltip').default} ttCtx
*/
_showTooltipBar(i, j, ttCtx) {
const w = this.w
// Mirror the runtime check in handleStickyCapturedSeries: only use shared
// mode when x values actually align across all series at this index.
const shared =
ttCtx.tConfig.shared &&
(ttCtx.tooltipUtil.isXoverlap(j) || w.globals.isBarHorizontal) &&
ttCtx.tooltipUtil.isInitialSeriesSameLen()
// Draw tooltip text content
const rangeData = /** @type {any} */ (w.rangeData.seriesRange)?.[i]?.[j]
?.y?.[0]
ttCtx.tooltipLabels.drawSeriesTexts({
ttItems: ttCtx.ttItems,
i,
j,
...(rangeData?.y1 !== undefined && { y1: rangeData.y1 }),
...(rangeData?.y2 !== undefined && { y2: rangeData.y2 }),
shared,
})
// Apply the hover visual state on the bar.
// Use Paper.findOne() to get the SVG.js wrapper (same as toggleDataPointSelection
// in UpdateHelpers.js) — querySelector returns a plain DOM node which lacks the
// .node property that pathMouseEnter requires.
const parent = `.apexcharts-series[data\\:realIndex='${i}']`
const elPath = w.dom.Paper.findOne(
`${parent} path[j='${j}'], ${parent} circle[j='${j}'], ${parent} rect[j='${j}']`,
)
if (elPath) {
// Leave the previous bar before entering the new one
this._leaveHoveredBar()
const graphics = new Graphics(this.w, this.ctx)
graphics.pathMouseEnter(elPath, null)
this._hoveredBarEl = elPath
}
if (w.globals.isBarHorizontal) {
// Horizontal rangeBar / timeline: position tooltip using viewport-relative
// coords to avoid grid-space vs wrapper-space confusion.
const barDomEl = elPath && elPath.node
if (barDomEl) {
const wrapRect = w.dom.elWrap.getBoundingClientRect()
const barRect = barDomEl.getBoundingClientRect()
// Bar centre in elWrap-relative coordinates
const barCx = barRect.left - wrapRect.left // left edge of bar
const barCy = barRect.top - wrapRect.top // top edge of bar
const bh = barRect.height
const bw = barRect.width
const ttWidth = ttCtx.tooltipRect.ttWidth || 0
const ttHeight = ttCtx.tooltipRect.ttHeight || 0
// Vertically: centre the tooltip on the bar
const y = barCy + bh / 2 - ttHeight / 2
// Horizontally: place tooltip at the bar's right edge (positive values)
// or left of bar start for negative bars (same logic as Intersect)
let x = barCx + bw
const baselineX =
ttCtx.xyRatios && ttCtx.xyRatios.baseLineInvertedY != null
? ttCtx.xyRatios.baseLineInvertedY
: wrapRect.width / 2
if (barCx < baselineX) {
x = barCx - ttWidth
}
const tooltipEl = ttCtx.getElTooltip()
if (tooltipEl) {
tooltipEl.style.left = x + 'px'
tooltipEl.style.top = y + 'px'
}
}
} else {
// Vertical bar / column / candlestick / boxPlot
ttCtx.tooltipPosition.moveStickyTooltipOverBars(j, i)
}
}
/**
* line / area / scatter / bubble / radar / rangeArea
* @param {number} i
* @param {number} j
* @param {import('../tooltip/Tooltip').default} ttCtx
*/
_showTooltipAxisLine(i, j, ttCtx) {
const w = this.w
const type = w.config.chart.type
// Mirror the runtime check in handleStickyCapturedSeries: tooltip.shared
// only applies when all series have the same x value at index j and the
// same number of data points. For irregular time series (different x
// values across series), fall back to individual (non-shared) tooltip so
// only the currently focused series is shown — matching mouse behaviour.
const sharedConfigured = ttCtx.tConfig.shared
const shared =
sharedConfigured &&
ttCtx.tooltipUtil.isXoverlap(j) &&
ttCtx.tooltipUtil.isInitialSeriesSameLen()
ttCtx.tooltipLabels.drawSeriesTexts({
ttItems: ttCtx.ttItems,
i,
j,
shared,
})
// Scatter and bubble charts use intersect mode — each data point is a real
// SVG marker element with cx/cy attributes set by Scatter.js.
// pointsArray is only populated for null/invalid data points, so
// moveDynamicPointOnHover fails for valid scatter/bubble data.
//
// enlargePoints(j) searches all series for markers where rel===j, which
// causes two problems for keyboard nav:
// 1. Multiple bubbles across series enlarge at once (wrong for single selection)
// 2. Tooltip ends up positioned over the LAST matching marker, not series i
//
// Instead we replicate what Intersect.handleMarkerTooltip does: find the
// specific marker for (i, j), resize only it, and position the tooltip
// at its cx/cy using the same formula as mouse hover.
const isScatterLike = type === 'scatter' || type === 'bubble'
const hasVisibleMarkers = w.globals.markers.largestSize > 0
if (isScatterLike) {
this._showScatterBubblePoint(i, j, ttCtx)
} else if (hasVisibleMarkers) {
// Line/area with visible permanent markers
if (shared) {
ttCtx.marker.enlargePoints(j)
} else {
// irregular series — only enlarge the focused series' marker
ttCtx.tooltipPosition.moveDynamicPointOnHover(j, i)
}
} else if (shared) {
// shared=true, x values match — show dynamic point on all series
ttCtx.tooltipPosition.moveDynamicPointsOnHover(j)
} else {
// shared=false or x values differ — show dynamic point on this series only
ttCtx.tooltipPosition.moveDynamicPointOnHover(j, i)
}
}
/**
* Scatter / bubble: find the specific marker element for (seriesIndex i,
* dataPointIndex j), resize only that element, and position the tooltip at
* its coordinates — mirroring what Position.moveMarkers does for mouse hover.
*
* Unlike enlargePoints(j) which queries ALL series for rel===j (causing
* multiple bubbles to enlarge and tooltip to land on the wrong one), this
* method queries by both series index AND data-point index for precision.
* @param {number} i
* @param {number} j
* @param {import('../tooltip/Tooltip').default} ttCtx
*/
_showScatterBubblePoint(i, j, ttCtx) {
const baseEl = this.w.dom.baseEl
// Reset only the previously enlarged marker (not all markers via
// resetPointsSize()). Calling resetPointsSize() modifies the `d`
// attribute on every marker element in the DOM, which can trigger
// synthetic mouseout events on those elements — the Tooltip mouse
// listeners catch those and call handleMouseOut → resetPointsSize()
// again, creating a resize ping-pong that appears as jitter.
if (this._enlargedScatterMarker) {
ttCtx.marker.oldPointSize(this._enlargedScatterMarker)
this._enlargedScatterMarker = null
}
// Find the marker for this exact series (data:realIndex attr) and data
// point (rel attr). The element hierarchy is:
// .apexcharts-series[data:realIndex='i'] > ... > .apexcharts-marker[rel='j']
const seriesEl = baseEl.querySelector(
`.apexcharts-series[data\\:realIndex='${i}']`,
)
if (!seriesEl) return
const markerEl = seriesEl.querySelector(`.apexcharts-marker[rel='${j}']`)
if (!markerEl) return
// enlargeCurrentPoint already handles bubble (skips resize since bubble
// radius encodes a data dimension), reads cx/cy from the element, and
// delegates positioning to moveTooltip — which correctly adds translateX.
ttCtx.marker.enlargeCurrentPoint(j, markerEl)
// Remember which element was enlarged so we can reset only it next time.
this._enlargedScatterMarker = markerEl
}
/**
* pie / donut / polarArea
* @param {number} i
* @param {number} j
* @param {import('../tooltip/Tooltip').default} ttCtx
* @param {HTMLElement} tooltipEl
*/
_showTooltipNonAxis(i, j, ttCtx, tooltipEl) {
const w = this.w
// For pie-like charts the series index IS the data point / slice index
ttCtx.tooltipLabels.drawSeriesTexts({
ttItems: ttCtx.ttItems,
i: j,
shared: false,
})
// Refresh tooltip dimensions after content is drawn
const tooltipBound = tooltipEl.getBoundingClientRect()
const ttWidth = tooltipBound.width || ttCtx.tooltipRect.ttWidth || 0
const ttHeight = tooltipBound.height || ttCtx.tooltipRect.ttHeight || 0
// Use data:cx / data:cy — the arc centroid in SVG/grid-space computed by
// Pie.js (same values that nonAxisChartsTooltips uses for intersect mode).
// The path element carries j='${j}' (0-indexed). data:cx/cy are set on
// the path directly (not on the parent group).
const sliceEl = w.dom.baseEl.querySelector(`.apexcharts-pie-area[j='${j}']`)
if (sliceEl) {
const cx = parseFloat(sliceEl.getAttribute('data:cx') ?? '')
const cy = parseFloat(sliceEl.getAttribute('data:cy') ?? '')
if (!isNaN(cx) && !isNaN(cy)) {
// Convert SVG-space to elWrap-relative (same transform as mouse path)
const svgBound = w.dom.Paper.node.getBoundingClientRect()
const wrapBound = w.dom.elWrap.getBoundingClientRect()
const offsetX = svgBound.left - wrapBound.left
const offsetY = svgBound.top - wrapBound.top
tooltipEl.style.left = offsetX + cx - ttWidth / 2 + 'px'
tooltipEl.style.top = offsetY + cy - ttHeight - 10 + 'px'
}
}
}
/**
* radialBar — one ring per series, single value each
* @param {number} i
* @param {any} _j
* @param {import('../tooltip/Tooltip').default} ttCtx
* @param {HTMLElement} tooltipEl
*/
_showTooltipRadialBar(i, _j, ttCtx, tooltipEl) {
const w = this.w
ttCtx.tooltipLabels.drawSeriesTexts({
ttItems: ttCtx.ttItems,
i,
shared: false,
})
const { ttWidth = 0, ttHeight = 0 } = ttCtx.getCachedDimensions()
// Each radial series is a ring; find the path and use its data:angle to
// compute the centroid at the midpoint of the arc.
const arcEl = w.dom.baseEl.querySelector(
`.apexcharts-radialbar-series[data\\:realIndex='${i}'] path`,
)
if (arcEl) {
const angle = parseFloat(arcEl.getAttribute('data:angle') ?? '') || 0
// Radial bars start from the top (initialAngle) and sweep clockwise
const initialAngle = w.config.plotOptions.radialBar.startAngle || 0
const midAngle = initialAngle + angle / 2
const centerX = w.layout.gridWidth / 2
const centerY = w.layout.gridHeight / 2
const radialSize =
w.globals.radialSize ||
Math.min(w.layout.gridWidth, w.layout.gridHeight) / 2
// Use the outer radius for this particular ring (series i)
const seriesCount = w.seriesData.series.length
const trackSize = radialSize / Math.max(seriesCount, 1)
const outerRadius = radialSize - i * trackSize
const innerRadius = outerRadius - trackSize
const ringRadius = (outerRadius + innerRadius) / 2
const centroid = Utils.polarToCartesian(
centerX,
centerY,
ringRadius,
midAngle,
)
const x = centroid.x + (w.layout.translateX || 0)
const y = centroid.y + (w.layout.translateY || 0)
tooltipEl.style.left = x - ttWidth / 2 + 'px'
tooltipEl.style.top = y - ttHeight - 10 + 'px'
}
}
/**
* heatmap / treemap — position tooltip using element bounding rect
* @param {number} i
* @param {number} j
* @param {import('../tooltip/Tooltip').default} ttCtx
* @param {HTMLElement} tooltipEl
* @param {string} type
*/
_showTooltipHeatTree(i, j, ttCtx, tooltipEl, type) {
const w = this.w
ttCtx.tooltipLabels.drawSeriesTexts({
ttItems: ttCtx.ttItems,
i,
j,
shared: false,
})
// Refresh tooltip dimensions after content is drawn
const tooltipRect = tooltipEl.getBoundingClientRect()
const ttWidth = tooltipRect.width || ttCtx.tooltipRect.ttWidth || 0
const ttHeight = tooltipRect.height || ttCtx.tooltipRect.ttHeight || 0
const rectClass =
type === 'heatmap' ? 'apexcharts-heatmap-rect' : 'apexcharts-treemap-rect'
const cell = w.dom.baseEl.querySelector(`.${rectClass}[i='${i}'][j='${j}']`)
if (cell) {
// Use viewport-relative rects so we don't need to worry about SVG
// translate offsets (cx/cy on these elements are in grid-space).
const wrapRect = w.dom.elWrap.getBoundingClientRect()
const cellRect = cell.getBoundingClientRect()
const cellCx = cellRect.left - wrapRect.left
const cellCy = cellRect.top - wrapRect.top
const cellWidth = cellRect.width
const cellHeight = cellRect.height
// Move crosshair to horizontal centre of cell
const cx = parseFloat(cell.getAttribute('cx') ?? '')
const cellWidthAttr = parseFloat(cell.getAttribute('width') ?? '')
ttCtx.tooltipPosition.moveXCrosshairs(cx + cellWidthAttr / 2)
// Position tooltip to the right of the cell, vertically centred;
// flip left if it would overflow the right half of the grid.
let x = cellCx + cellWidth + ttWidth / 2
const y = cellCy + cellHeight / 2 - ttHeight / 2
if (cellCx + cellWidth > w.layout.gridWidth / 2) {
x = cellCx - ttWidth / 2
}
tooltipEl.style.left = x + 'px'
tooltipEl.style.top = y + 'px'
}
}
// ─── Focus class management ───────────────────────────────────────────────
/**
* @param {number} i
* @param {number} j
*/
_applyFocusClass(i, j) {
this._removeFocusClass()
const el = this._getFocusableElement(i, j)
if (el) {
el.classList.add('apexcharts-keyboard-focused')
this._focusedEl = el
}
}
_removeFocusClass() {
if (this._focusedEl) {
this._focusedEl.classList.remove('apexcharts-keyboard-focused')
this._focusedEl = null
}
}
_leaveHoveredBar() {
if (this._hoveredBarEl) {
const graphics = new Graphics(this.w, this.ctx)
graphics.pathMouseLeave(this._hoveredBarEl, null)
this._hoveredBarEl = null
}
}
/**
* @param {number} i
* @param {number} j
*/
_getFocusableElement(i, j) {
const w = this.w
const type = w.config.chart.type
const baseEl = w.dom.baseEl
if (type === 'pie' || type === 'donut' || type === 'polarArea') {
// j is 0-indexed; the path carries j='${j}' (rel is on the parent group)
return baseEl.querySelector(`.apexcharts-pie-area[j='${j}']`)
}
if (type === 'heatmap') {
return baseEl.querySelector(
`.apexcharts-heatmap-rect[i='${i}'][j='${j}']`,
)
}
if (type === 'treemap') {
return baseEl.querySelector(
`.apexcharts-treemap-rect[i='${i}'][j='${j}']`,
)
}
if (type === 'radialBar') {
return baseEl.querySelector(
`.apexcharts-radialbar-series[data\\:realIndex='${i}'] path`,
)
}
if (
type === 'bar' ||
type === 'candlestick' ||
type === 'boxPlot' ||
type === 'rangeBar'
) {
return baseEl.querySelector(
`.apexcharts-series[data\\:realIndex='${i}'] path[j='${j}']`,
)
}
// line / area / scatter / bubble / radar — try marker element
const marker = baseEl.querySelector(
`.apexcharts-series[data\\:realIndex='${i}'] .apexcharts-marker[rel='${j}']`,
)
return marker || null
}
// ─── Click / Enter ────────────────────────────────────────────────────────
_fireClick() {
const w = this.w
const ttCtx = w.globals.tooltip
if (!ttCtx) return
const syntheticEvent = {
type: 'mouseup',
clientX: 0,
clientY: 0,
}
ttCtx.markerClick(syntheticEvent, this.seriesIndex, this.dataPointIndex)
}
// ─── Helpers ──────────────────────────────────────────────────────────────
_isNavEnabled() {
const a11y = this.w.config.chart.accessibility
return (
a11y.enabled && a11y.keyboard.enabled && a11y.keyboard.navigation.enabled
)
}
_getSeriesCount() {
const w = this.w
const type = w.config.chart.type
// Non-axis charts: pie/donut/polarArea navigate slices, not series
if (type === 'pie' || type === 'donut' || type === 'polarArea') {
return 1 // single "series" — all navigation is on dataPointIndex
}
return w.seriesData.series.length
}
/**
* @param {number} si
*/
_getDataPointCount(si) {
const w = this.w
const type = w.config.chart.type
// Non-axis charts: globals.series is a flat array of values (one per slice)
if (type === 'pie' || type === 'donut' || type === 'polarArea') {
return w.seriesData.series.length
}
const series = w.seriesData.series
return series[si] && Array.isArray(series[si]) ? series[si].length : 0
}
_clampCursor() {
const seriesCount = this._getSeriesCount()
if (this.seriesIndex >= seriesCount) this.seriesIndex = seriesCount - 1
if (this.seriesIndex < 0) this.seriesIndex = 0
const dpCount = this._getDataPointCount(this.seriesIndex)
if (this.dataPointIndex >= dpCount) this.dataPointIndex = dpCount - 1
if (this.dataPointIndex < 0) this.dataPointIndex = 0
}
/**
* When the chart is zoomed in, the current dataPointIndex may point to a
* data point that is outside the visible viewport. Snap the cursor to the
* first data point whose x-value falls within [minX, maxX].
*
* Only adjusts when w.seriesData.seriesX is populated (numeric/datetime axes).
* Category-only charts (seriesX entries are strings or auto-indices) are
* unaffected — all points are always visible.
*/
_snapToVisibleRange() {
const w = this.w
const gl = w.globals
const si = this.seriesIndex
// No zoom applied — nothing to do
if (!w.interact.zoomed) return
const seriesX = w.seriesData.seriesX && w.seriesData.seriesX[si]
if (!seriesX || !seriesX.length) return
const minX = gl.minX
const maxX = gl.maxX
if (minX === undefined || maxX === undefined) return
// Check if the current data point is already visible
const currentX = seriesX[this.dataPointIndex]
if (currentX >= minX && currentX <= maxX) return
// Find the first data point within [minX, maxX]
const dpCount = seriesX.length
for (let di = 0; di < dpCount; di++) {
if (seriesX[di] >= minX && seriesX[di] <= maxX) {
this.dataPointIndex = di
return
}
}
// If no data point is in range (shouldn't happen in practice), leave cursor as-is
}
/**
* Snap to the nearest visible data point in the given navigation direction.
* direction > 0 → find the first visible point (left boundary of zoomed range)
* direction < 0 → find the last visible point (right boundary of zoomed range)
* @param {number} direction
*/
_snapToVisibleRangeInDirection(direction) {
const w = this.w
const gl = w.globals
const si = this.seriesIndex
const seriesX = w.seriesData.seriesX && w.seriesData.seriesX[si]
if (!seriesX || !seriesX.length) return
const minX = gl.minX
const maxX = gl.maxX
if (minX === undefined || maxX === undefined) return
const dpCount = seriesX.length
if (direction >= 0) {
// Going right: snap to first visible point
for (let di = 0; di < dpCount; di++) {
if (seriesX[di] >= minX && seriesX[di] <= maxX) {
this.dataPointIndex = di
return
}
}
} else {
// Going left: snap to last visible point
for (let di = dpCount - 1; di >= 0; di--) {
if (seriesX[di] >= minX && seriesX[di] <= maxX) {
this.dataPointIndex = di
return
}
}
}
}
/**
* Check whether the data point at (si, di) is within the current visible
* x-axis range. Used to skip out-of-viewport points during keyboard nav.
* @param {number} si
* @param {number} di
*/
_isDataPointVisible(si, di) {
const w = this.w
const gl = w.globals
if (!w.interact.zoomed) return true
const seriesX = w.seriesData.seriesX && w.seriesData.seriesX[si]
if (!seriesX) return true
const x = seriesX[di]
if (x === undefined) return true
return x >= gl.minX && x <= gl.maxX
}
}