UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,667 lines 56.2 kB
// @ts-check
import Labels from './Labels'
import Position from './Position'
import Marker, { renderMarkerSVG } from './Marker'
import Intersect from './Intersect'
import AxesTooltip from './AxesTooltip'
import { BrowserAPIs } from '../../ssr/BrowserAPIs.js'
import Graphics from '../Graphics'
import Series from '../Series'
import XAxis from './../axes/XAxis'
import Utils from './Utils'
import { isCustom } from '../ChartFactory'

/**
 * ApexCharts Core Tooltip Class to handle the tooltip generation.
 *
 * @module Tooltip
 **/

export default class Tooltip {
  /**
   * @param {import('../../types/internal').ChartStateW} w
   * @param {import('../../types/internal').ChartContext} ctx
   */
  constructor(w, ctx) {
    this.w = w
    this.ctx = ctx // needed: getGroupedCharts, getSyncedCharts, fireEvent, XAxis instantiation

    this.tConfig = w.config.tooltip

    this.tooltipUtil = new Utils(this)
    this.tooltipLabels = new Labels(this)
    this.tooltipPosition = new Position(this)
    this.marker = new Marker(this)
    this.intersect = new Intersect(this)
    this.axesTooltip = new AxesTooltip(this)
    this.showOnIntersect = this.tConfig.intersect
    this.showTooltipTitle = this.tConfig.x.show
    this.fixedTooltip = this.tConfig.fixed.enabled
    /** @type {HTMLElement | null} */
    this.xaxisTooltip = null
    /** @type {HTMLElement | null} */
    this.xaxisTooltipText = null
    /** @type {HTMLElement | null} */
    this.yaxisTooltip = null
    /** @type {HTMLElement[] | null} */
    this.yaxisTooltipText = null
    /** @type {HTMLElement[] | null} */
    this.yaxisTTEls = null
    /** @type {number} */
    this.xaxisOffY = 0
    /** @type {number} */
    this.yaxisOffX = 0
    /** @type {number} */
    this.xcrosshairsWidth = 0
    /** @type {Element | null} */
    this.ycrosshairs = null
    /** @type {Element | null} */
    this.ycrosshairsHidden = null
    /** @type {Element | null} */
    this.tooltip = null
    /** @type {any} */
    this.e = null
    this.isBarShared = !w.globals.isBarHorizontal && this.tConfig.shared
    this.lastHoverTime = Date.now()
    this.dimensionUpdateScheduled = false

    // Properties set in drawTooltip() / create() / event handlers
    /** @type {import('../../types/internal').XYRatios | null} */
    this.xyRatios = null
    /** @type {boolean} */
    this.isXAxisTooltipEnabled = false
    /** @type {boolean[]} */
    this.yaxisTooltips = []
    /** @type {any} */
    this.allTooltipSeriesGroups = []
    /** @type {any} */
    this.xAxisTicksPositions = null
    /** @type {number} */
    this.dataPointsDividedHeight = 0
    /** @type {number} */
    this.dataPointsDividedWidth = 0
    /** @type {HTMLElement | null} */
    this.tooltipTitle = null
    /** @type {NodeListOf<Element> | null} */
    this.legendLabels = null
    /** @type {any} */
    this.ttItems = null
    /** @type {DOMRect | null} */
    this.seriesBound = null
    /** @type {ReturnType<typeof setTimeout> | undefined} */
    this.seriesHoverTimeout = undefined
    /** @type {number} */
    this.clientX = 0
    /** @type {number} */
    this.clientY = 0
    /** @type {number} */
    this.barSeriesHeight = 0
    /** @type {{ x: number, y: number, ttWidth: number, ttHeight: number }} */
    this.tooltipRect = { x: 0, y: 0, ttWidth: 0, ttHeight: 0 }
  }

  setupDimensionCache() {
    const w = this.w
    const tooltipEl = this.getElTooltip()

    if (!tooltipEl) return

    // Initial dimension cache
    this.updateDimensionCache()

    // Setup ResizeObserver for automatic dimension updates
    if (typeof ResizeObserver !== 'undefined' && !w.globals.resizeObserver) {
      w.globals.resizeObserver = new ResizeObserver(() => {
        if (!this.dimensionUpdateScheduled) {
          this.dimensionUpdateScheduled = true
          requestAnimationFrame(() => {
            this.updateDimensionCache()
            this.dimensionUpdateScheduled = false
          })
        }
      })
      w.globals.resizeObserver.observe(tooltipEl)
    }
  }

  updateDimensionCache() {
    const w = this.w
    const tooltipEl = this.getElTooltip()

    if (!tooltipEl) return

    const rect = tooltipEl.getBoundingClientRect()
    w.globals.dimensionCache.tooltip = /** @type {any} */ ({
      width: rect.width,
      height: rect.height,
      lastUpdate: Date.now(),
    })
  }

  getCachedDimensions() {
    const w = this.w

    // Return cached dimensions if available and fresh (< 1 second old)
    if (w.globals.dimensionCache.tooltip) {
      const cache = /** @type {Record<string,any>} */ (
        w.globals.dimensionCache.tooltip
      )
      const age = Date.now() - cache.lastUpdate

      if (age < 1000) {
        return {
          ttWidth: cache.width,
          ttHeight: cache.height,
        }
      }
    }

    // Fallback to live measurement and update cache
    this.updateDimensionCache()
    const cache = /** @type {Record<string,any>} */ (
      w.globals.dimensionCache.tooltip
    )
    return cache
      ? {
          ttWidth: cache.width,
          ttHeight: cache.height,
        }
      : { ttWidth: 0, ttHeight: 0 }
  }

  /**
   * @param {{ w: import('../../types/internal').ChartStateW }} [ctx]
   * @returns {HTMLElement | null}
   */
  getElTooltip(ctx) {
    if (!ctx) ctx = this
    if (!ctx.w.dom.baseEl) return null

    // :not() is required: the point-annotation hover tooltip shares the
    // .apexcharts-tooltip class for styling and, once created, can precede
    // the series tooltip in DOM order (drawTooltip re-appends the series
    // tooltip on fastUpdate). Without the guard this would return the
    // annotation tooltip and hijack the series-tooltip machinery.
    return /** @type {HTMLElement | null} */ (
      ctx.w.dom.baseEl.querySelector(
        '.apexcharts-tooltip:not(.apexcharts-annotation-tooltip)',
      )
    )
  }

  getElXCrosshairs() {
    return this.w.dom.baseEl.querySelector('.apexcharts-xcrosshairs')
  }

  getElGrid() {
    return this.w.dom.baseEl.querySelector('.apexcharts-grid')
  }

  /**
   * @param {import('../../types/internal').XYRatios} xyRatios
   */
  drawTooltip(xyRatios) {
    const w = this.w
    this.xyRatios = xyRatios
    this.isXAxisTooltipEnabled =
      w.config.xaxis.tooltip.enabled && w.globals.axisCharts
    /**
     * @param {number} y
     */
    this.yaxisTooltips = w.config.yaxis.map((y) => {
      return y.show && y.tooltip.enabled && w.globals.axisCharts ? true : false
    })
    /** @type {any} */
    this.allTooltipSeriesGroups = []

    if (!w.globals.axisCharts) {
      this.showTooltipTitle = false
    }

    // Remove any existing tooltip element to avoid duplicates on re-draws
    // (e.g. fastUpdate path calls drawTooltip without full DOM teardown)
    const existingTooltip = this.getElTooltip()
    if (existingTooltip?.parentNode) {
      existingTooltip.parentNode.removeChild(existingTooltip)
    }
    this.tooltipTitle = null

    const tooltipEl = BrowserAPIs.createElementNS(
      'http://www.w3.org/1999/xhtml',
      'div',
    )
    tooltipEl.classList.add('apexcharts-tooltip')
    if (w.config.tooltip.cssClass) {
      tooltipEl.classList.add(w.config.tooltip.cssClass)
    }
    tooltipEl.classList.add(`apexcharts-theme-${this.tConfig.theme || 'light'}`)

    // `fillSeriesColor` paints each series-group with its series colour.
    // Tag the tooltip so the CSS can drop the glass body (transparent bg,
    // no border, no backdrop-filter) and clip the coloured series-group(s)
    // to the body's rounded corners — otherwise the colour block sits
    // inside a glass shell with visible padding/border around it.
    if (this.tConfig.fillSeriesColor) {
      tooltipEl.classList.add('apexcharts-tooltip-fill-series')
    }

    // `compact`: one tight line instead of a card. The x label loses its own
    // title bar and sits inline before the value, the marker goes, and the
    // padding and font shrink. Meant for panels a normal card would cover
    // (small multiples, sparklines, dashboard tiles). With a SINGLE series the
    // series-name label goes too, because in a one-series panel it repeats
    // what the panel header already says; with several series the names are
    // the only thing distinguishing the rows, so they stay.
    if (this.tConfig.compact) {
      tooltipEl.classList.add('apexcharts-tooltip-compact')
      if (w.config.series.length === 1) {
        tooltipEl.classList.add('apexcharts-tooltip-value-only')
      }
    }

    // Optional user-supplied solid background — set on the CSS variable so
    // the rest of the glass theme (border, shadow) still applies cleanly.
    // An opaque value also visually disables the backdrop blur.
    if (this.tConfig.style && this.tConfig.style.background) {
      tooltipEl.style.setProperty(
        '--apx-tt-bg',
        this.tConfig.style.background,
      )
    }

    // Arrow connector (default on via `tooltip.arrow`). Positioned by
    // Position.applyTooltipPosition() via the `--apx-tt-arrow-y` CSS var.
    // Skip the arrow in these cases:
    //  - `followCursor`: the cursor IS the anchor; arrow would be redundant.
    //  - `fixed.enabled`: tooltip is pinned to a chart corner, not a point,
    //    so an arrow would point nowhere.
    //  - shared tooltip on a multi-series chart: there's no single point to
    //    anchor to, so an arrow would mislead. Exceptions: horizontal-bar
    //    charts (a single row of bars per category — tooltip goes above/below
    //    the row union) and heatmaps (the hover resolves a single cell —
    //    tooltip goes above/below that cell). Both keep a meaningful arrow
    //    target, so they're allowed an arrow despite `shared` being on.
    //  - non-axis charts (pie/donut/radialBar/polarArea): slices radiate
    //    from a centre, so there's no single "anchor edge" for the arrow.
    //  - `fillSeriesColor`: the glass arrow would not match the solid
    //    series-colour body. Better to drop it than render a colour clash.
    const isSharedMulti =
      this.tConfig.shared &&
      w.config.series.length > 1 &&
      !w.globals.isBarHorizontal &&
      w.config.chart.type !== 'heatmap'
    const shouldDrawArrow =
      this.tConfig.arrow &&
      !this.tConfig.followCursor &&
      !this.tConfig.fixed.enabled &&
      !isSharedMulti &&
      !this.tConfig.fillSeriesColor &&
      w.globals.axisCharts
    if (shouldDrawArrow) {
      const arrowEl = BrowserAPIs.createElementNS(
        'http://www.w3.org/1999/xhtml',
        'div',
      )
      arrowEl.classList.add('apexcharts-tooltip-arrow')
      tooltipEl.appendChild(arrowEl)
    }

    // accessibility attributes
    if (
      w.config.chart.accessibility.enabled &&
      w.config.chart.accessibility.announcements.enabled
    ) {
      tooltipEl.setAttribute('role', 'tooltip')
      tooltipEl.setAttribute('aria-live', 'polite')
      tooltipEl.setAttribute('aria-atomic', 'true')
      tooltipEl.setAttribute('aria-hidden', 'true')
    }

    w.dom.elWrap.appendChild(tooltipEl)


    if (w.globals.axisCharts) {
      this.axesTooltip.drawXaxisTooltip()
      this.axesTooltip.drawYaxisTooltip()
      this.axesTooltip.setXCrosshairWidth()
      this.axesTooltip.handleYCrosshair()

      const xAxis = new XAxis(this.w, this.ctx, undefined)
      this.xAxisTicksPositions = xAxis.getXAxisTicksPositions()
    }

    // we forcefully set intersect true for these conditions
    if (
      (w.globals.comboCharts ||
        this.tConfig.intersect ||
        w.config.chart.type === 'rangeBar') &&
      !this.tConfig.shared
    ) {
      this.showOnIntersect = true
    }

    if (
      w.config.markers.size === 0 ||
      w.globals.markers.largestSize === 0 ||
      // batched markers have no per-point node to enlarge, so the hover dot is
      // served by the same single marker a markers.size: 0 chart uses
      w.globals.markers.batched
    ) {
      // when user don't want to show points all the time, but only on when hovering on series
      this.marker.drawDynamicPoints()
    }

    // no visible series, exit
    if (w.globals.collapsedSeries.length === w.seriesData.series.length) return

    this.dataPointsDividedHeight = w.layout.gridHeight / w.globals.dataPoints
    this.dataPointsDividedWidth = w.layout.gridWidth / w.globals.dataPoints

    if (this.showTooltipTitle) {
      this.tooltipTitle = BrowserAPIs.createElementNS(
        'http://www.w3.org/1999/xhtml',
        'div',
      )
      this.tooltipTitle.classList.add('apexcharts-tooltip-title')
      this.tooltipTitle.style.fontFamily =
        this.tConfig.style.fontFamily || w.config.chart.fontFamily
      this.tooltipTitle.style.fontSize = this.tConfig.style.fontSize
      tooltipEl.appendChild(this.tooltipTitle)
    }

    let ttItemsCnt = w.seriesData.series.length // whether shared or not, default is shared
    if ((w.globals.xyCharts || w.globals.comboCharts) && this.tConfig.shared) {
      if (!this.showOnIntersect) {
        ttItemsCnt = w.seriesData.series.length
      } else {
        ttItemsCnt = 1
      }
    }

    this.legendLabels = w.dom.baseEl.querySelectorAll('.apexcharts-legend-text')

    this.ttItems = this.createTTElements(ttItemsCnt)
    this.addSVGEvents()

    this.setupDimensionCache()
  }

  /**
   * @param {number} ttItemsCnt
   */
  createTTElements(ttItemsCnt) {
    const w = this.w
    /** @type {any[]} */
    const ttItems = []

    const tooltipEl = this.getElTooltip()
    if (!tooltipEl) return ttItems
    for (let i = 0; i < ttItemsCnt; i++) {
      const gTxt = BrowserAPIs.createElementNS(
        'http://www.w3.org/1999/xhtml',
        'div',
      )

      gTxt.classList.add(
        'apexcharts-tooltip-series-group',
        `apexcharts-tooltip-series-group-${i}`,
      )
      gTxt.style.order = String(
        w.config.tooltip.inverseOrder ? ttItemsCnt - i : i + 1,
      )

      const point = BrowserAPIs.createElementNS(
        'http://www.w3.org/1999/xhtml',
        'span',
      )
      point.classList.add('apexcharts-tooltip-marker')

      if (w.config.tooltip.fillSeriesColor) {
        point.style.backgroundColor = w.globals.colors[i]
      } else {
        point.style.color = w.globals.colors[i]
      }

      const mShape = w.config.markers.shape
      let shape = mShape
      if (Array.isArray(mShape)) {
        shape = mShape[i]
      }

      point.setAttribute('shape', shape)
      // Inline SVG renders crisper than the previous unicode-glyph
      // `::before` pseudo-elements (which rendered at different sizes on
      // Windows vs macOS).
      point.innerHTML = renderMarkerSVG(shape)
      gTxt.appendChild(point)

      const gYZ = BrowserAPIs.createElementNS(
        'http://www.w3.org/1999/xhtml',
        'div',
      )
      gYZ.classList.add('apexcharts-tooltip-text')

      gYZ.style.fontFamily =
        this.tConfig.style.fontFamily || w.config.chart.fontFamily
      gYZ.style.fontSize = this.tConfig.style.fontSize
      ;['y', 'goals', 'z'].forEach((g) => {
        const gValText = BrowserAPIs.createElementNS(
          'http://www.w3.org/1999/xhtml',
          'div',
        )
        gValText.classList.add(`apexcharts-tooltip-${g}-group`)

        const txtLabel = BrowserAPIs.createElementNS(
          'http://www.w3.org/1999/xhtml',
          'span',
        )
        txtLabel.classList.add(`apexcharts-tooltip-text-${g}-label`)
        gValText.appendChild(txtLabel)

        const txtValue = BrowserAPIs.createElementNS(
          'http://www.w3.org/1999/xhtml',
          'span',
        )
        txtValue.classList.add(`apexcharts-tooltip-text-${g}-value`)
        gValText.appendChild(txtValue)

        gYZ.appendChild(gValText)
      })

      gTxt.appendChild(gYZ)

      tooltipEl.appendChild(gTxt)

      ttItems.push(gTxt)
    }

    return ttItems
  }

  addSVGEvents() {
    const w = this.w
    const type = w.config.chart.type
    const tooltipEl = this.getElTooltip()
    if (!tooltipEl) return

    const commonBar = !!(
      type === 'bar' ||
      type === 'candlestick' ||
      type === 'boxPlot' ||
      type === 'violin' ||
      type === 'rangeBar'
    )

    const chartWithmarkers =
      type === 'area' ||
      type === 'line' ||
      type === 'scatter' ||
      type === 'bubble' ||
      type === 'radar'

    // Radar is the only chart that is hit-tested through its markers while not
    // being an xyChart (`Core` puts it in axisCharts but not xyChartsArrTypes),
    // so it matches neither the shared `hoverArea` branch below, which is
    // xyCharts/comboCharts only, nor the `!axisCharts` branch. That left a hole:
    // whenever `showOnIntersect` was false, radar was wired to nothing at all
    // and no tooltip could ever appear.
    //
    // `tooltip.shared: true` lands exactly in that hole, because Config rejects
    // shared together with intersect, so asking for a shared tooltip forces
    // intersect off and takes the hover listeners away with it. Radar therefore
    // needs the per-marker listeners in both modes. See #1575.
    const isPolarMarkerChart = chartWithmarkers && !w.globals.xyCharts

    const hoverArea = w.dom.Paper.node

    const elGrid = this.getElGrid()
    if (elGrid) {
      this.seriesBound = elGrid.getBoundingClientRect()
    }

    /** @type {any[]} */
    const tooltipY = []
    /** @type {any[]} */
    const tooltipX = []

    const seriesHoverParams = {
      hoverArea,
      elGrid,
      tooltipEl,
      tooltipY,
      tooltipX,
      ttItems: this.ttItems,
    }

    let points

    if (w.globals.axisCharts) {
      if (chartWithmarkers) {
        points = w.dom.baseEl.querySelectorAll(
          ".apexcharts-series[data\\:longestSeries='true'] .apexcharts-marker",
        )
      } else if (commonBar) {
        points = w.dom.baseEl.querySelectorAll(
          '.apexcharts-series .apexcharts-bar-area, .apexcharts-series .apexcharts-candlestick-area, .apexcharts-series .apexcharts-boxPlot-area, .apexcharts-series .apexcharts-violin-area, .apexcharts-series .apexcharts-rangebar-area',
        )
      } else if (type === 'heatmap' || type === 'treemap') {
        points = w.dom.baseEl.querySelectorAll(
          '.apexcharts-series .apexcharts-heatmap, .apexcharts-series .apexcharts-treemap',
        )
      }

      if (points && points.length) {
        for (let p = 0; p < points.length; p++) {
          tooltipY.push(points[p].getAttribute('cy'))
          tooltipX.push(points[p].getAttribute('cx'))
        }
      }
    }

    const validSharedChartTypes =
      (w.globals.xyCharts && !this.showOnIntersect) ||
      (w.globals.comboCharts && !this.showOnIntersect) ||
      (commonBar && this.tooltipUtil.hasBars() && this.tConfig.shared)

    if (validSharedChartTypes) {
      this.addPathsEventListeners([hoverArea], seriesHoverParams)
    } else if (
      (commonBar && !w.globals.comboCharts) ||
      (chartWithmarkers && this.showOnIntersect) ||
      isPolarMarkerChart
    ) {
      this.addDatapointEventsListeners(seriesHoverParams)
    } else if (
      type === 'heatmap' &&
      w.globals.activeRenderer &&
      w.globals.activeRenderer.kind === 'canvas'
    ) {
      // Canvas heatmap: cells are painted (no per-cell <rect> to hover) and the
      // series groups are empty, so hover the whole plot and resolve the cell by
      // coordinate (Intersect.handleHeatTreeTooltip -> renderer.hitTest).
      this.addPathsEventListeners([hoverArea], seriesHoverParams)
    } else if (
      !w.globals.axisCharts ||
      type === 'heatmap' ||
      type === 'treemap'
    ) {
      const seriesAll = w.dom.baseEl.querySelectorAll('.apexcharts-series')
      this.addPathsEventListeners(seriesAll, seriesHoverParams)
    }

    if (this.showOnIntersect) {
      const lineAreaPoints = w.dom.baseEl.querySelectorAll(
        '.apexcharts-line-series .apexcharts-marker, .apexcharts-area-series .apexcharts-marker',
      )
      if (lineAreaPoints.length > 0) {
        // if we find any lineSeries, addEventListeners for them
        this.addPathsEventListeners(lineAreaPoints, seriesHoverParams)
      }

      // combo charts may have bars, so add event listeners here too
      if (this.tooltipUtil.hasBars() && !this.tConfig.shared) {
        this.addDatapointEventsListeners(seriesHoverParams)
      }
    }
  }

  drawFixedTooltipRect() {
    const w = this.w

    const tooltipEl = this.getElTooltip()
    if (!tooltipEl) return { x: 0, y: 0, ttWidth: 0, ttHeight: 0 }

    const tooltipRect = tooltipEl.getBoundingClientRect()

    const ttWidth = tooltipRect.width + 10
    const ttHeight = tooltipRect.height + 10
    let x = this.tConfig.fixed.offsetX
    let y = this.tConfig.fixed.offsetY

    const fixed = this.tConfig.fixed.position.toLowerCase()

    if (fixed.indexOf('right') > -1) {
      x = x + w.globals.svgWidth - ttWidth + 10
    }
    if (fixed.indexOf('bottom') > -1) {
      y = y + w.globals.svgHeight - ttHeight - 10
    }

    // Route through the single tooltip-position writer so the fixed-mode
    // path uses the same code as moveTooltip/Intersect.
    // No arrow placement here — fixed-mode tooltips don't track a point.
    this.tooltipPosition.applyTooltipPosition(tooltipEl, { x, y })

    return {
      x,
      y,
      ttWidth,
      ttHeight,
    }
  }

  /**
   * @param {Record<string, any>} seriesHoverParams
   */
  addDatapointEventsListeners(seriesHoverParams) {
    const w = this.w
    const points = w.dom.baseEl.querySelectorAll(
      '.apexcharts-series-markers .apexcharts-marker, .apexcharts-bar-area, .apexcharts-candlestick-area, .apexcharts-boxPlot-area, .apexcharts-rangebar-area',
    )
    this.addPathsEventListeners(points, seriesHoverParams)
  }

  /**
   * @param {any} paths
   * @param {Record<string, any>} opts
   */
  addPathsEventListeners(paths, opts) {
    const self = this

    for (let p = 0; p < paths.length; p++) {
      const extendedOpts = {
        paths: paths[p],
        tooltipEl: opts.tooltipEl,
        tooltipY: opts.tooltipY,
        tooltipX: opts.tooltipX,
        elGrid: opts.elGrid,
        hoverArea: opts.hoverArea,
        ttItems: opts.ttItems,
      }

      const events = [
        'mousemove',
        'mouseup',
        'touchmove',
        'mouseout',
        'touchend',
      ]

      events.map((ev) => {
        return paths[p].addEventListener(
          ev,
          self.onSeriesHover.bind(self, extendedOpts),
          { capture: false, passive: true },
        )
      })
    }
  }

  /*
   ** Check to see if the tooltips should be updated based on a mouse / touch event
   * @param {Record<string, any>} opt
   * @param {Event} e
   */
  /** @param {Record<string, any>} opt @param {any} e */
  onSeriesHover(opt, e) {
    // Note down the element under the pointer NOW, while the event is still
    // propagating. The draw below can be deferred past the end of dispatch, and
    // by then a chart inside a shadow root reports the host element as the
    // target instead of the bar/marker/cell we need (#3237).
    Utils.hoverTarget(e)

    // If a user is moving their mouse quickly, don't bother updating the tooltip every single frame

    const targetDelay = 20
    const timeSinceLastUpdate = Date.now() - this.lastHoverTime
    if (timeSinceLastUpdate >= targetDelay) {
      // The tooltip was last updated over 100ms ago - redraw it even if the user is still moving their
      // mouse so they get some feedback that their moves are being registered
      this.seriesHover(opt, e)
    } else {
      // The tooltip was last updated less than 100ms ago
      // Cancel any other delayed draw, so we don't show stale data
      clearTimeout(this.seriesHoverTimeout)

      // Schedule the next draw so that it happens about 100ms after the last update
      this.seriesHoverTimeout = setTimeout(() => {
        this.seriesHover(opt, e)
      }, targetDelay - timeSinceLastUpdate)
    }
  }

  /*
   ** The actual series hover function
   * @param {Record<string, any>} opt
   * @param {Event} e
   */
  /** @param {Record<string, any>} opt @param {any} e */
  seriesHover(opt, e) {
    // seriesHover can be scheduled via a ~20ms setTimeout (seriesHoverTimeout);
    // if the chart is destroyed inside that window the timer still fires. Bail so
    // it does not run against a torn-down chart (nulled baseEl / tooltip).
    if (this.w.globals.isDestroyed) return
    this.lastHoverTime = Date.now()
    let chartGroups = []
    const w = this.w

    // Crossfilter filter-mode member (Linked Views #4): its categories are one
    // dimension of a shared record set, unrelated to a sibling's dimension, so
    // the group's index-matched tooltip sync is meaningless there (hovering
    // "Loss" would caption the sibling's same-index bucket, e.g. "Q1"). Such
    // charts neither source nor receive the sync; the coordinator owns their
    // cross-chart behavior.
    const isCfMember = (/** @type {any} */ chart) => {
      const link = chart?.w?.config?.chart?.link
      return !!(link && typeof link.dimension === 'function')
    }

    // if user has more than one charts in group, we need to sync.
    // Use getSyncedCharts() (self + siblings), not getGroupedCharts()
    // (siblings only): the hovered chart must draw its OWN tooltip too,
    // otherwise the one chart under the cursor is the only one left blank.
    if (w.config.chart.group && !isCfMember(this.ctx)) {
      chartGroups = this.ctx
        .getSyncedCharts()
        .filter((/** @type {any} */ ch) => !isCfMember(ch))
    }

    if (
      w.globals.axisCharts &&
      ((w.globals.minX === -Infinity && w.globals.maxX === Infinity) ||
        w.globals.dataPoints === 0)
    ) {
      return
    }

    if (chartGroups.length) {
      /**
       * @param {Record<string, any>} ch
       */
      chartGroups.forEach((ch) => {
        const tooltipEl = this.getElTooltip(ch)

        const newOpts = {
          paths: opt.paths,
          tooltipEl,
          tooltipY: opt.tooltipY,
          tooltipX: opt.tooltipX,
          elGrid: opt.elGrid,
          hoverArea: opt.hoverArea,
          ttItems: ch.w.globals.tooltip.ttItems,
        }

        // all the charts should have the same minX and maxX (same xaxis) for multiple tooltips to work correctly
        if (
          ch.w.globals.minX === this.w.globals.minX &&
          ch.w.globals.maxX === this.w.globals.maxX
        ) {
          ch.w.globals.tooltip.seriesHoverByContext({
            chartCtx: ch,
            ttCtx: ch.w.globals.tooltip,
            opt: newOpts,
            e,
          })
        }
      })
    } else {
      this.seriesHoverByContext({
        chartCtx: this.ctx,
        ttCtx: this.w.globals.tooltip,
        opt,
        e,
      })
    }
  }

  /** @param {{chartCtx: any, ttCtx: any, opt: any, e: any}} opts */
  seriesHoverByContext({ chartCtx, ttCtx, opt, e }) {
    const w = chartCtx.w
    const tooltipEl = this.getElTooltip(chartCtx)

    if (!tooltipEl) return

    // use cached dimensions instead of live getBoundingClientRect
    const cachedDims = ttCtx.getCachedDimensions()
    ttCtx.tooltipRect = {
      x: 0,
      y: 0,
      ttWidth: cachedDims.ttWidth,
      ttHeight: cachedDims.ttHeight,
    }
    ttCtx.e = e

    // highlight the current hovered bars
    if (
      ttCtx.tooltipUtil.hasBars() &&
      !w.globals.comboCharts &&
      !ttCtx.isBarShared
    ) {
      if (this.tConfig.onDatasetHover.highlightDataSeries) {
        const series = new Series(chartCtx.w)
        series.toggleSeriesOnHover(e, Utils.hoverTarget(e)?.parentNode)
      }
    }

    if (w.globals.axisCharts) {
      ttCtx.axisChartsTooltips({
        e,
        opt,
        tooltipRect: ttCtx.tooltipRect,
      })
    } else {
      // non-plot charts i.e pie/donut/circle
      ttCtx.nonAxisChartsTooltips({
        e,
        opt,
        tooltipRect: ttCtx.tooltipRect,
      })
    }

    if (ttCtx.fixedTooltip) {
      ttCtx.drawFixedTooltipRect()
    }
  }

  // tooltip handling for line/area/bar/columns/scatter
  /** @param {{e: any, opt: any}} opts */
  axisChartsTooltips({ e, opt }) {
    const w = this.w
    let x, y

    // Every hit-test below is measured against the grid, so without one there
    // is nothing to hover. `drawSeriesTooltip` already treats a missing grid as
    // normal (it skips caching `seriesBound`), and a pointer event can still
    // arrive with the grid gone: a cross-type morph tears down the axis chrome
    // while the listeners bound to the old plot are still live.
    if (!opt.elGrid) return

    const seriesBound = opt.elGrid.getBoundingClientRect()

    const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX
    const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY

    this.clientY = clientY
    this.clientX = clientX

    w.interact.capturedSeriesIndex = -1
    w.interact.capturedDataPointIndex = -1

    if (
      clientY < seriesBound.top ||
      clientY > seriesBound.top + seriesBound.height
    ) {
      this.handleMouseOut(opt)
      return
    }

    // Point-annotation hover tooltip (apexcharts/apexcharts.js#2424): while
    // one is showing (pointer over an annotation marker), keep the series
    // tooltip hidden, as both boxes would anchor to the same spot and read
    // as a glitch. The annotation's mouseleave deactivates it, so the very
    // next mousemove restores the series tooltip.
    if (
      w.dom.elWrap.querySelector(
        '.apexcharts-annotation-tooltip.apexcharts-active',
      )
    ) {
      this.handleMouseOut(opt)
      return
    }

    if (
      Array.isArray(this.tConfig.enabledOnSeries) &&
      !w.config.tooltip.shared
    ) {
      const index = parseInt(opt.paths.getAttribute('index'), 10)
      if (this.tConfig.enabledOnSeries.indexOf(index) < 0) {
        this.handleMouseOut(opt)
        return
      }
    }

    const tooltipEl = this.getElTooltip()
    if (!tooltipEl) return
    const xcrosshairs = this.getElXCrosshairs()

    // A 2-D cell chart is hit-tested per CELL, not per x index, so it has its
    // own handler and must never fall into the sticky/synced path below: that
    // path resolves one x index, prints every series row for it, and anchors
    // the card to the axis rather than the cell. In a group (a trellis panel
    // is always grouped) that turned a one-line cell tooltip into an
    // all-rows card pinned to the first column.
    const isCellChart = ['heatmap', 'treemap'].includes(w.config.chart.type)

    let syncedCharts = []
    if (w.config.chart.group && !isCellChart) {
      // we need to fallback to sticky tooltip in case charts are synced
      syncedCharts = this.ctx.getSyncedCharts()
    }

    const isStickyTooltip =
      w.globals.xyCharts ||
      (w.config.chart.type === 'bar' &&
        !w.globals.isBarHorizontal &&
        this.tooltipUtil.hasBars() &&
        this.tConfig.shared) ||
      (w.globals.comboCharts && this.tooltipUtil.hasBars())

    if (
      e.type === 'mousemove' ||
      e.type === 'touchmove' ||
      e.type === 'mouseup'
    ) {
      // there is no series to hover over
      if (
        w.globals.collapsedSeries.length +
          w.globals.ancillaryCollapsedSeries.length ===
        w.seriesData.series.length
      ) {
        return
      }

      if (xcrosshairs !== null) {
        xcrosshairs.classList.add('apexcharts-active')
      }

      const hasYAxisTooltip = this.yaxisTooltips?.filter(
        (/** @type {any} */ b) => {
          return b === true
        },
      )
      // ycrosshairs is set dynamically during drawTooltip()
      const _yc = /** @type {any} */ (this).ycrosshairs
      if (_yc !== null && hasYAxisTooltip?.length) {
        _yc.classList.add('apexcharts-active')
      }

      if (
        !isCellChart &&
        ((isStickyTooltip && !this.showOnIntersect) || syncedCharts.length > 1)
      ) {
        this.handleStickyTooltip(e, clientX, clientY, opt)
      } else {
        if (
          w.config.chart.type === 'heatmap' ||
          w.config.chart.type === 'treemap'
        ) {
          const markerXY = this.intersect.handleHeatTreeTooltip({
            e,
            opt,
            x,
            y,
            type: w.config.chart.type,
          })
          if (markerXY.noHit) {
            // canvas heatmap: pointer is off every cell -> hide, don't pin one
            this.handleMouseOut(opt)
            return
          }
          x = markerXY.x
          y = markerXY.y

          if (!markerXY.positioned) {
            // Legacy beside-the-cell placement writes style directly. Arrow-mode
            // heatmaps have already positioned via applyTooltipPosition (which
            // also sets the arrow and data-placement), so don't overwrite it.
            tooltipEl.style.left = x + 'px'
            tooltipEl.style.top = y + 'px'
          }
        } else {
          if (this.tooltipUtil.hasBars()) {
            this.intersect.handleBarTooltip({
              e,
              opt,
            })
          }

          if (this.tooltipUtil.hasMarkers(0)) {
            // intersect - line/area/scatter/bubble
            this.intersect.handleMarkerTooltip({
              e,
              opt,
              x,
              y,
            })
          }
        }
      }

      if (this.yaxisTooltips && this.yaxisTooltips.length) {
        for (let yt = 0; yt < w.config.yaxis.length; yt++) {
          this.axesTooltip.drawYaxisTooltipText(
            yt,
            clientY,
            /** @type {import('../../types/internal').XYRatios} */ (
              this.xyRatios
            ),
          )
        }
      }

      w.dom.baseEl.classList.add('apexcharts-tooltip-active')
      opt.tooltipEl.classList.add('apexcharts-active')
      if (
        w.config.chart.accessibility.enabled &&
        w.config.chart.accessibility.announcements.enabled
      ) {
        opt.tooltipEl.removeAttribute('aria-hidden')
      }
    } else if (e.type === 'mouseout' || e.type === 'touchend') {
      this.handleMouseOut(opt)
    }
  }

  /**
   * Where a pie / donut / polarArea slice wants its tooltip anchored, in
   * elWrap-relative pixels.
   *
   * Pie.js stamps the arc centroid on the path as `data:cx` / `data:cy`, in
   * the slice's OWN user space: below the inner group's translate, and below
   * the pie group's customScale. Reading those as if they were SVG-root
   * coordinates silently drops both, and the inner translate is exactly the
   * offset that centres a pie in a chart wider than it is tall, so on such a
   * chart the tooltip landed a couple of hundred pixels to the left of the
   * slice it described. The element's screen matrix accounts for every
   * ancestor transform at once, including the translate a slice picks up while
   * it is slid out on click.
   *
   * @param {any} el a slice path carrying data:cx / data:cy
   * @returns {{x: number, y: number} | null} null when it carries neither
   */
  getSliceAnchor(el) {
    const w = this.w
    const cx = parseFloat(el?.getAttribute('data:cx') ?? '')
    const cy = parseFloat(el?.getAttribute('data:cy') ?? '')
    if (isNaN(cx) || isNaN(cy)) return null

    const wrapBound = w.dom.elWrap.getBoundingClientRect()
    const ctm = typeof el.getScreenCTM === 'function' ? el.getScreenCTM() : null

    if (!ctm) {
      // No matrix support (jsdom): fall back to the SVG root's own offset,
      // which is right whenever nothing above the slice is transformed.
      const svgBound = w.dom.Paper.node.getBoundingClientRect()
      return {
        x: svgBound.left - wrapBound.left + cx,
        y: svgBound.top - wrapBound.top + cy,
      }
    }

    return {
      x: ctm.a * cx + ctm.c * cy + ctm.e - wrapBound.left,
      y: ctm.b * cx + ctm.d * cy + ctm.f - wrapBound.top,
    }
  }

  // tooltip handling for pie/donuts
  /** @param {{e: any, opt: any, tooltipRect: any}} opts */
  nonAxisChartsTooltips({ e, opt, tooltipRect }) {
    const w = this.w
    const rel = opt.paths.getAttribute('rel')

    const tooltipEl = this.getElTooltip()
    if (!tooltipEl) return

    const seriesBound = w.dom.elWrap.getBoundingClientRect()

    if (e.type === 'mousemove' || e.type === 'touchmove') {
      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')
      }

      // Unit chart: the listener sits on the cluster GROUP, but the hovered
      // element is the individual dot. Resolve it so each dot tooltips its
      // own identity (category + index within the category) instead of every
      // dot repeating the cluster aggregate.
      if (w.config.chart.type === 'unit') {
        const hovered = Utils.hoverTarget(e)
        const unitDot =
          hovered && typeof hovered.closest === 'function'
            ? hovered.closest('.apexcharts-unit-area')
            : null
        // Over a label / gap (no dot): leave the last tooltip untouched.
        if (!unitDot) return
        this.renderUnitTooltip(unitDot)
      } else {
        this.tooltipLabels.drawSeriesTexts({
          ttItems: opt.ttItems,
          i: parseInt(rel, 10) - 1,
          shared: false,
        })
      }

      let x, y

      // opt.paths is the <g class="apexcharts-series"> group element;
      // data:cx / data:cy are set on the child <path> arc element inside it
      const arcPath = opt.paths.querySelector('path[data\\:cx]') || opt.paths
      const anchor = w.config.tooltip.intersect
        ? this.getSliceAnchor(arcPath)
        : null

      if (anchor) {
        x = anchor.x - tooltipRect.ttWidth / 2
        y = anchor.y - tooltipRect.ttHeight - 10
      } else {
        x =
          (w.interact.clientX ?? 0) - seriesBound.left - tooltipRect.ttWidth / 2
        y =
          (w.interact.clientY ?? 0) -
          seriesBound.top -
          tooltipRect.ttHeight -
          10
      }

      tooltipEl.style.left = x + 'px'
      tooltipEl.style.top = y + 'px'

      if (w.config.legend.tooltipHoverFormatter) {
        const legendFormatter = w.config.legend.tooltipHoverFormatter

        const i = rel - 1
        const legendEl = /** @type {HTMLElement | undefined} */ (
          this.legendLabels?.[i]
        )
        if (!legendEl) return
        const legendName = legendEl.getAttribute('data:default-text')

        const text = legendFormatter(legendName, {
          seriesIndex: i,
          dataPointIndex: i,
          w,
        })

        legendEl.innerHTML = text
      }
    } else if (e.type === 'mouseout' || e.type === 'touchend') {
      tooltipEl.classList.remove('apexcharts-active')
      w.dom.baseEl.classList.remove('apexcharts-tooltip-active')
      if (w.config.legend.tooltipHoverFormatter) {
        this.legendLabels?.forEach((l) => {
          const defaultText = l.getAttribute('data:default-text')
          /** @type {HTMLElement} */ l.innerHTML = decodeURIComponent(
            defaultText ?? '',
          )
        })
      }
    }
  }

  /**
   * Fill the tooltip for one hovered unit-chart dot. Each dot carries `i` (its
   * category / series index) and `j` (its index within that category). The
   * default body reads "#<j+1> of <count>"; `plotOptions.unit.tooltip.formatter`
   * overrides just the body text (it is handed i/j so it can look up per-unit
   * data), and the global `tooltip.custom` still overrides the whole markup.
   * @param {Element} dotEl the hovered `.apexcharts-unit-area` node
   */
  renderUnitTooltip(dotEl) {
    const w = this.w
    const tooltipEl = this.getElTooltip()
    if (!tooltipEl) return

    const i = parseInt(dotEl.getAttribute('i') || '0', 10)
    const j = parseInt(dotEl.getAttribute('j') || '0', 10)

    // A user-supplied global custom tooltip owns the whole markup; hand it the
    // resolved per-unit i/j (dataPointIndex) so it can render per-unit content.
    if (typeof w.config.tooltip.custom === 'function') {
      this.tooltipLabels.handleCustomTooltip({ i, j, y1: null, y2: null, w })
      return
    }

    const seriesName = w.seriesData.seriesNames[i] || `series-${i + 1}`
    // Raw category value (e.g. 1000 people) vs. the number of DOTS actually
    // drawn for it (e.g. 10 when unitValue is 100, or fewer after a maxUnits
    // clip). "#j of count" indexes dots, so count must be the dot count; read it
    // from the hovered dot's own cluster group so it always matches the render.
    const value = Math.round(Number(w.seriesData.series[i]) || 0)
    const group = dotEl.parentNode
    const count =
      group && group.querySelectorAll
        ? group.querySelectorAll('.apexcharts-unit-area').length
        : value
    const unitOpts = w.config.plotOptions.unit || {}
    const unitValue = unitOpts.unitValue > 0 ? unitOpts.unitValue : 1

    // Per-unit datum (object-form input): this dot's own data object/primitive.
    const catData = w.seriesData.unitData && w.seriesData.unitData[i]
    const datum = catData ? catData[j] : undefined
    const datumObj = datum && typeof datum === 'object' ? datum : null
    // A per-unit fillColor tints the tooltip marker to match the dot.
    const color =
      (datumObj && datumObj.fillColor) ||
      (w.globals.colors && w.globals.colors[i]) ||
      '#008FFB'

    let body
    const fmt = unitOpts.tooltip && unitOpts.tooltip.formatter
    if (typeof fmt === 'function') {
      body = fmt({
        seriesName,
        seriesIndex: i,
        dataPointIndex: j,
        count,
        value,
        unitValue,
        datum,
        color,
        w,
      })
    } else if (datum !== undefined && datum !== null) {
      // Per-unit datum: show its own name/label and value when present, else the
      // primitive value itself.
      const label = datumObj
        ? (datumObj.name ?? datumObj.label ?? datumObj.x ?? null)
        : null
      const dVal = datumObj ? (datumObj.value ?? datumObj.y ?? null) : datum
      body =
        label != null && dVal != null
          ? `${label}: ${dVal}`
          : label != null
            ? String(label)
            : dVal != null
              ? String(dVal)
              : `#${(j + 1).toLocaleString()} of ${count.toLocaleString()}`
    } else {
      // Flat-count input: the dot has no data of its own, so give its position.
      body = `#${(j + 1).toLocaleString()} of ${count.toLocaleString()}`
      // When one dot stands for many units, spell that out.
      if (unitValue !== 1) {
        body += ` &middot; ${unitValue.toLocaleString()} per dot`
      }
    }

    const fontFamily = w.config.chart.fontFamily || 'inherit'
    const fontSize =
      (w.config.tooltip.style && w.config.tooltip.style.fontSize) || '12px'

    // The arrow is a sibling of the content; detach so innerHTML keeps it.
    const arrowEl = tooltipEl.querySelector('.apexcharts-tooltip-arrow')
    tooltipEl.innerHTML =
      `<div class="apexcharts-tooltip-title" style="font-family: ${fontFamily}; font-size: ${fontSize};">${seriesName}</div>` +
      `<div class="apexcharts-tooltip-series-group apexcharts-active" style="display: flex;">` +
      `<span class="apexcharts-tooltip-marker" style="background-color: ${color};"></span>` +
      `<div class="apexcharts-tooltip-text" style="font-family: ${fontFamily}; font-size: ${fontSize};">` +
      `<div class="apexcharts-tooltip-y-group">` +
      `<span class="apexcharts-tooltip-text-y-value">${body}</span>` +
      `</div></div></div>`
    if (arrowEl) tooltipEl.appendChild(arrowEl)

    // Content changed the box size; refresh the cached rect so the positioning
    // that follows centres on the true width/height.
    const rect = tooltipEl.getBoundingClientRect()
    this.tooltipRect.ttWidth = rect.width
    this.tooltipRect.ttHeight = rect.height
  }

  /**
   * @param {Event} e
   * @param {number} clientX
   * @param {number} clientY
   * @param {Record<string, any>} opt
   */
  handleStickyTooltip(e, clientX, clientY, opt) {
    const w = this.w
    const capj = this.tooltipUtil.getNearestValues({
      context: this,
      hoverArea: opt.hoverArea,
      elGrid: opt.elGrid,
      clientX,
      clientY,
    })

    const j = capj.j
    let capturedSeries = capj.capturedSeries

    if (
      capturedSeries !== null &&
      w.globals.collapsedSeriesIndices.includes(capturedSeries ?? -1)
    )
      capturedSeries = null

    // Bars at the first/last data point straddle the plot edge: their centers
    // sit at 0 and `gridWidth`, so half of `barPadForNumericAxis` hangs outside
    // the plot on each side. Hovering that half still has to resolve a tooltip,
    // so widen the bound by the pad rather than clipping at the plot box.
    const edgePad = w.globals.barPadForNumericAxis || 0
    if (capj.hoverX < -edgePad || capj.hoverX > w.layout.gridWidth + edgePad) {
      this.handleMouseOut(opt)
      return
    }

    if (capturedSeries !== null) {
      this.handleStickyCapturedSeries(e, capturedSeries ?? -1, opt, j ?? 0)
    } else {
      // couldn't capture any series. check if shared X is same,
      // if yes, draw a grouped tooltip
      if (this.tooltipUtil.isXoverlap(j ?? 0) || w.globals.isBarHorizontal) {
        const firstVisibleSeries = w.seriesData.series.findIndex(
          /**
           * @param {any} s
           * @param {number} i
           */
          (s, i) => !w.globals.collapsedSeriesIndices.includes(i),
        )
        this.create(e, this, firstVisibleSeries, j ?? 0, opt.ttItems)
      }
    }
  }

  /**
   * @param {Event} e
   * @param {number} capturedSeries
   * @param {Record<string, any>} opt
   * @param {number} j
   */
  handleStickyCapturedSeries(e, capturedSeries, opt, j) {
    const w = this.w
    if (!this.tConfig.shared) {
      const ignoreNull = w.seriesData.series[capturedSeries][j] === null
      if (ignoreNull) {
        this.handleMouseOut(opt)
        return
      }
    }

    if (typeof w.seriesData.series[capturedSeries][j] !== 'undefined') {
      if (
        this.tConfig.shared &&
        this.tooltipUtil.isXoverlap(j) &&
        this.tooltipUtil.isInitialSeriesSameLen()
      ) {
        this.create(e, this, capturedSeries, j, opt.ttItems)
      } else {
        this.create(e, this, capturedSeries, j, opt.ttItems, false)
      }
    } else {
      if (this.tooltipUtil.isXoverlap(j)) {
        const firstVisibleSeries = w.seriesData.series.findIndex(
          /**
           * @param {any} s
           * @param {number} i
           */
          (s, i) => !w.globals.collapsedSeriesIndices.includes(i),
        )
        this.create(e, this, firstVisibleSeries, j, opt.ttItems)
      }
    }
  }

  deactivateHoverFilter() {
    const w = this.w
    const graphics = new Graphics(this.w, this.ctx)

    const allPaths = w.dom.Paper.find(`.apexcharts-bar-area`)

    for (let b = 0; b < allPaths.length; b++) {
      graphics.pathMouseLeave(
        /** @type {any} */ (allPaths[b]),
        /** @type {any} */ (undefined),
      )
    }
  }

  /**
   * @param {Record<string, any>} opt
   */
  handleMouseOut(opt) {
    const w = this.w

    const xcrosshairs = this.getElXCrosshairs()
    w.dom.baseEl.classList.remove('apexcharts-tooltip-active')

    opt.tooltipEl.classList.remove('apexcharts-active')
    // Clear the first-paint marker so the next show repositions without
    // animating from the now-stale prior coords. Paired with the
    // `data-positioned` write in Position.applyTooltipPosition.
    delete opt.tooltipEl.dataset.positioned
    if (
      w.config.chart.accessibility.enabled &&
      w.config.chart.accessibility.announcements.enabled
    ) {
      opt.tooltipEl.setAttribute('aria-hidden', 'true')
    }
    this.deactivateHoverFilter()
    if (w.config.chart.type !== 'bubble') {
      this.marker.resetPointsSize()
    }
    if (xcrosshairs !== null) {
      xcrosshairs.classList.remove('apexcharts-active')
    }
    // ycrosshairs is set dynamically during drawTooltip()
    const _yc2 = /** @type {any} */ (this).ycrosshairs
    if (_yc2 !== null) {
      _yc2.classList.remove('apexcharts-active')
    }
    if (this.isXAxisTooltipEnabled) {
      this.xaxisTooltip?.classList.remove('apexcharts-active')
    }
    if (this.yaxisTooltips && this.yaxisTooltips.length) {
      if (this.yaxisTTEls === null) {
        this.yaxisTTEls = /** @type {HTMLElement[]} */ ([
          ...w.dom.baseEl.querySelectorAll('.apexcharts-yaxistooltip'),
        ])
      }
      for (let i = 0; i < this.yaxisTTEls.length; i++) {
        this.yaxisTTEls[i].classList.remove('apexcharts-active')
      }
    }

    if (w.config.legend.tooltipHoverFormatter) {
      this.legendLabels?.forEach((l) => {
        const defaultText = l.getAttribute('data:default-text')
        /** @type {HTMLElement} */ l.innerHTML = decodeURIComponent(
          defaultText ?? '',
        )
      })
    }
  }

  /**
   * @param {Event} e
   * @param {number} seriesIndex
   * @param {number} dataPointIndex
   */
  markerClick(e, seriesIndex, dataPointIndex) {
    const w = this.w
    if (typeof w.config.chart.events.markerClick === 'function') {
      w.config.chart.events.markerClick(e, this.ctx, {
        seriesIndex,
        dataPointIndex,
        w,
      })
    }
    this.ctx.events.fireEvent('markerClick', [
      e,
      this.ctx,
      { seriesIndex, dataPointIndex, w },
    ])
  }

  /**
   * Marks (#11): whether the chart's type (or any series' type) is a registered
   * custom series, whose marks live outside the built-in marker DOM.
   * @returns {boolean}
   */
  _hasCustomSeries() {
    const w = this.w
    if (isCustom(w.config.chart.type)) return true
    const series = w.config.series || []
    return series.some(
      (/** @type {any} */ s) => s && s.type && isCustom(s.type),
    )
  }

  /**
   * @param {Event} e
   * @param {any} context
   * @param {number} capturedSeries
   * @param {number} j
   * @param {any} ttItems
   * @param {boolean | null} shared
   */
  create(e, context, capturedSeries, j, ttItems, shared = null) {
    const w = this.w
    const ttCtx = context

    if (e.type === 'mouseup') {
      this.markerClick(e, capturedSeries, j)
    }

    if (shared === null) shared = this.tConfig.shared

    const hasMarkers = this.tooltipUtil.hasMarkers(capturedSeries)
    // Strata (#2): in canvas mode there are no per-point marker nodes, so the
    // DOM-driven position path (hasMarkers) is skipped and the tooltip would
    // stay pinned at the origin. The pixel coords live in w.globals.pointsArray
    // (populated by the canvas emit sites), so route positioning through the
    // pointsArray-based dynamic positioners instead.
    const canvasMode = this.ctx?.renderer?.kind === 'canvas'
    // Bars/candles keep the hasBars() -> moveStickyTooltipOverBars path (which
    // reads the canvas coord cache); only non-bar canvas series (line/area/
    // scatter markers) route through the pointsArray positioners here.
    const canvasNonBar = canvasMode && !this.tooltipUtil.hasBars()
    // Marks (#11): custom series paint their own marks (no .apexcharts-marker
    // nodes to enlarge/position), but they populate w.globals.pointsArray just
    // like canvas does. So when a custom-series chart has no markers and no
    // bars, route positioning through the same pointsArray positioners; without
    // this the tooltip activates but never moves off the origin.
    const marksMode =
      !canvasMode &&
      !hasMarkers &&
      !this.tooltipUtil.hasBars() &&
      this._hasCustomSeries()
    const dynamicPoints = canvasNonBar || marksMode

    const bars = this.tooltipUtil.getElBars()

    const handlePoints = () => {
      if (
        w.globals.markers.largestSize > 0 &&
        !canvasMode &&
        !w.globals.markers.batched
      ) {
        ttCtx.marker.enlargePoints(j)
      } else {
        // canvas: markers paint to a bitmap with no node to enlarge, so the
        // box is positioned off the cached pointsArray coords instead. Batched
        // markers are one path per series, with the same consequence.
        ttCtx.tooltipPosition.moveDynamicPointsOnHover(j)
      }
    }

    if (w.config.legend.tooltipHoverFormatter) {
      const legendFormatter = w.config.legend.tooltipHoverFormatter

      const els = /** @type {HTMLElement[]} */ (
        Array.from(this.legendLabels ?? [])
      )

      // reset all legend values first
      els.forEach((l) => {
        const legendName = l.getAttribute('data:default-text')
        l.innerHTML = decodeURIComponent(legendName ?? '')
      })

      // for irregular time series
      for (let i = 0; i < els.length; i++) {
        const l = els[i]
        const lsIndex = parseInt(l.getAttribute('i') ?? '', 10)
        const legendName = decodeURIComponent(
          l.getAttribute('data:default-text') ?? '',
        )

        const text = legendFormatter(legendName, {
          seriesIndex: shared ? lsIndex : capturedSeries,
          dataPointIndex: j,
          w,
        })

        if (!shared) {
          l.innerHTML = lsIndex === capturedSeries ? text : legendName
          if (capturedSeries === lsIndex) {
            break
          }
        } else {
          l.innerHTML =
            w.globals.collapsedSeriesIndices.indexOf(lsIndex) < 0
              ? text
              : legendName
        }
      }
    }

    const _rangeData = /** @type {any} */ (w.rangeData)
    const commonSeriesTextsParams = {
      ttItems,
      i: capturedSeries,
      j,
      ...(_rangeData.seriesRange?.[capturedSeries]?.[j]?.y[0]?.y1 !==
        undefined && {
        y1: _rangeData.seriesRange?.[capturedSeries]?.[j]?.y[0]?.y1,
      }),
      ...(_rangeData.seriesRange?.[capturedSeries]?.[j]?.y[0]?.y2 !==
        undefined && {
        y2: _rangeData.seriesRange?.[capturedSeries]?.[j]?.y[0]?.y2,
      }),
    }
    if (shared) {
      ttCtx.tooltipLabels.drawSeriesTexts({
        ...commonSeriesTextsParams,
        shared: this.showOnIntersect ? false : this.tConfig.shared,
      })

      if (hasMarkers || dynamicPoints) {
        handlePoints()
      } else if (this.tooltipUtil.hasBars()) {
        if (canvasMode) {
          // canvas: bars/candles paint to the bitmap, so the barSeriesHeight /
          // paths / hover-filter machinery below is DOM-only and no-ops here.
          // Position straight off the cached bar coords (barSeriesHeight then
          // resolves to 0, so the SVG block is skipped).
          ttCtx.tooltipPosition.moveStickyTooltipOverBars(j, capturedSeries)
        }
        this.barSeriesHeight = this.tooltipUtil.getBarsHeight(
          /** @type {any[]} */ ([...bars]),
        )
        if (this.barSeriesHeight > 0) {
          // hover state, activate snap filter
          const graphics = new Graphics(this.w, this.ctx)
          const paths = w.dom.Paper.find(`.apexcharts-bar-area[j='${j}']`)

          // de-activate first
          this.deactivateHoverFilter()
          const points = ttCtx.tooltipUtil.getAllMarkers(true)

          if (points.length && !this.barSeriesHeight) {
            handlePoints()
          }

          ttCtx.tooltipPosition.moveStickyTooltipOverBars(j, capturedSeries)

          for (let b = 0; b < paths.length; b++) {
            graphics.pathMouseEnter(
              /** @type {any} */ (paths[b]),
              /** @type {any} */ (undefined),
            )
          }
        }
      }
    } else {
      ttCtx.tooltipLabels.drawSeriesTexts({
        shared: false,
        ...commonSeriesTextsParams,
      })

      if (this.tooltipUtil.hasBars()) {
        ttCtx.tooltipPosition.moveStickyTooltipOverBars(j, capturedSeries)
      }

      if (hasMarkers) {
        ttCtx.tooltipPosition.moveMarkers(capturedSeries, j)
      } else if (dynamicPoints) {
        // canvas / custom marks: no marker node to enlarge; position off the
        // cached pointsArray coords. (bars already positioned above.)
        ttCtx.tooltipPosition.moveDynamicPointOnHover(j, capturedSeries)
      }
    }
  }
}