UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,651 lines 50.2 kB
// @ts-check
import Animations from './Animations'
import Filters from './Filters'
import Utils from '../utils/Utils'

/**
 * ApexCharts Graphics Class for all drawing operations.
 *
 * @module Graphics
 **/

class Graphics {
  /**
   * @param {import('../types/internal').ChartStateW} w
   * @param {import('../types/internal').ChartContext | null} ctx
   */
  constructor(w, ctx = null) {
    this.w = w
    this.ctx = ctx
  }

  /*****************************************************************************
   *                                                                            *
   *  SVG Path Rounding Function                                                *
   *  Copyright (C) 2014 Yona Appletree                                         *
   *                                                                            *
   *  Licensed under the Apache License, Version 2.0 (the "License");           *
   *  you may not use this file except in compliance with the License.          *
   *  You may obtain a copy of the License at                                   *
   *                                                                            *
   *      http://www.apache.org/licenses/LICENSE-2.0                            *
   *                                                                            *
   *  Unless required by applicable law or agreed to in writing, software       *
   *  distributed under the License is distributed on an "AS IS" BASIS,         *
   *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  *
   *  See the License for the specific language governing permissions and       *
   *  limitations under the License.                                            *
   *                                                                            *
   *****************************************************************************/

  /**
   * SVG Path rounding function. Takes an input path string and outputs a path
   * string where all line-line corners have been rounded. Only supports absolute
   * commands at the moment.
   *
   * @param pathString The SVG input path
   * @param radius The amount to round the corners, either a value in the SVG
   *               coordinate space, or, if useFractionalRadius is true, a value
   *               from 0 to 1.
   * @returns A new SVG path string with the rounding
   * @param {string} pathString
   * @param {number} radius
   */
  roundPathCorners(pathString, radius) {
    if (pathString.indexOf('NaN') > -1) pathString = ''

    /**
     * @param {{x: number, y: number}} movingPoint
     * @param {{x: number, y: number}} targetPoint
     * @param {number} amount
     */
    function moveTowardsLength(movingPoint, targetPoint, amount) {
      var width = targetPoint.x - movingPoint.x
      var height = targetPoint.y - movingPoint.y

      var distance = Math.sqrt(width * width + height * height)

      // Degenerate segment: the two points coincide, so there is no direction
      // to move along. `amount / 0` is NaN (radius 0) or Infinity, and either
      // poisons every coordinate downstream, a stacked bar whose layer has
      // collapsed to zero extent then hands morphSVG a path full of NaN, which
      // it can only rescue by dropping the mark on the chart origin. Stay put.
      if (!distance) {
        return { x: movingPoint.x, y: movingPoint.y }
      }

      return moveTowardsFractional(
        movingPoint,
        targetPoint,
        Math.min(1, amount / distance),
      )
    }
    /**
     * @param {{x: number, y: number}} movingPoint
     * @param {{x: number, y: number}} targetPoint
     * @param {number} fraction
     */
    function moveTowardsFractional(movingPoint, targetPoint, fraction) {
      return {
        x: movingPoint.x + (targetPoint.x - movingPoint.x) * fraction,
        y: movingPoint.y + (targetPoint.y - movingPoint.y) * fraction,
      }
    }

    // Adjusts the ending position of a command
    /**
     * @param {any} cmd
     * @param {{x: number, y: number}} newPoint
     */
    function adjustCommand(cmd, newPoint) {
      if (cmd.length > 2) {
        cmd[cmd.length - 2] = newPoint.x
        cmd[cmd.length - 1] = newPoint.y
      }
    }

    // Gives an {x, y} object for a command's ending position
    /**
     * @param {any} cmd
     */
    function pointForCommand(cmd) {
      return {
        x: parseFloat(cmd[cmd.length - 2]),
        y: parseFloat(cmd[cmd.length - 1]),
      }
    }

    // Split apart the path, handing concatonated letters and numbers
    var pathParts = pathString.split(/[,\s]/).reduce(function (
      /** @type {any} */ parts,
      /** @type {any} */ part,
    ) {
      var match = part.match(/^([a-zA-Z])(.+)/)
      if (match) {
        parts.push(match[1])
        parts.push(match[2])
      } else {
        parts.push(part)
      }

      return parts
    }, [])

    // Group the commands with their arguments for easier handling
    var commands = pathParts.reduce(function (
      /** @type {any} */ commands,
      /** @type {any} */ part,
    ) {
      if (parseFloat(part) == part && commands.length) {
        commands[commands.length - 1].push(part)
      } else {
        commands.push([part])
      }

      return commands
    }, [])

    // The resulting commands, also grouped
    var resultCommands = []

    if (commands.length > 1) {
      var startPoint = pointForCommand(commands[0])

      // Handle the close path case with a "virtual" closing line
      var virtualCloseLine = null
      if (commands[commands.length - 1][0] == 'Z' && commands[0].length > 2) {
        virtualCloseLine = ['L', startPoint.x, startPoint.y]
        commands[commands.length - 1] = virtualCloseLine
      }

      // We always use the first command (but it may be mutated)
      resultCommands.push(commands[0])

      for (var cmdIndex = 1; cmdIndex < commands.length; cmdIndex++) {
        var prevCmd = resultCommands[resultCommands.length - 1]

        var curCmd = commands[cmdIndex]

        // Handle closing case
        var nextCmd =
          curCmd == virtualCloseLine ? commands[1] : commands[cmdIndex + 1]

        // Nasty logic to decide if this path is a candidite.
        if (
          nextCmd &&
          prevCmd &&
          prevCmd.length > 2 &&
          curCmd[0] == 'L' &&
          nextCmd.length > 2 &&
          nextCmd[0] == 'L'
        ) {
          // Calc the points we're dealing with
          var prevPoint = pointForCommand(prevCmd)
          var curPoint = pointForCommand(curCmd)
          var nextPoint = pointForCommand(nextCmd)

          // The start and end of the cuve are just our point moved towards the previous and next points, respectivly
          var curveStart, curveEnd

          curveStart = moveTowardsLength(curPoint, prevPoint, radius)
          curveEnd = moveTowardsLength(curPoint, nextPoint, radius)

          // Adjust the current command and add it
          adjustCommand(curCmd, curveStart)
          curCmd.origPoint = curPoint
          resultCommands.push(curCmd)

          // The curve control points are halfway between the start/end of the curve and
          // the original point
          var startControl = moveTowardsFractional(curveStart, curPoint, 0.5)
          var endControl = moveTowardsFractional(curPoint, curveEnd, 0.5)

          // Create the curve
          var curveCmd = [
            'C',
            startControl.x,
            startControl.y,
            endControl.x,
            endControl.y,
            curveEnd.x,
            curveEnd.y,
          ]
          // Save the original point for fractional calculations
          // @ts-ignore — origPoint is a custom property added to the array command
          curveCmd.origPoint = curPoint
          resultCommands.push(curveCmd)
        } else {
          // Pass through commands that don't qualify
          resultCommands.push(curCmd)
        }
      }

      // Fix up the starting point and restore the close path if the path was orignally closed
      if (virtualCloseLine) {
        var newStartPoint = pointForCommand(
          resultCommands[resultCommands.length - 1],
        )
        resultCommands.push(['Z'])
        adjustCommand(resultCommands[0], newStartPoint)
      }
    } else {
      resultCommands = commands
    }

    /**
     * @param {string} str
     * @param {Record<string, any>} c
     */
    return resultCommands.reduce(function (
      /** @type {any} */ str,
      /** @type {any} */ c,
    ) {
      return str + c.join(' ') + ' '
    }, '')
  }

  /**
   * @param {number} x1
   * @param {number} y1
   * @param {number} x2
   * @param {number} y2
   * @param {number | null} [strokeWidth]
   */
  drawLine(
    x1,
    y1,
    x2,
    y2,
    lineColor = '#a8a8a8',
    dashArray = 0,
    strokeWidth = null,
    strokeLineCap = 'butt',
  ) {
    const w = this.w
    const line = w.dom.Paper.line().attr({
      x1,
      y1,
      x2,
      y2,
      stroke: lineColor,
      'stroke-dasharray': dashArray,
      'stroke-width': strokeWidth,
      'stroke-linecap': strokeLineCap,
    })

    return line
  }

  /**
   * @param {number | null} [strokeWidth]
   * @param {string | null} [strokeColor]
   */
  drawRect(
    x1 = 0,
    y1 = 0,
    x2 = 0,
    y2 = 0,
    radius = 0,
    color = '#fefefe',
    opacity = 1,
    strokeWidth = null,
    strokeColor = null,
    strokeDashArray = 0,
  ) {
    const w = this.w
    const rect = w.dom.Paper.rect()

    rect.attr({
      x: x1,
      y: y1,
      width: x2 > 0 ? x2 : 0,
      height: y2 > 0 ? y2 : 0,
      rx: radius,
      ry: radius,
      opacity,
      'stroke-width': strokeWidth !== null ? strokeWidth : 0,
      stroke: strokeColor !== null ? strokeColor : 'none',
      'stroke-dasharray': strokeDashArray,
    })

    // fix apexcharts.js#1410
    rect.node.setAttribute('fill', color)

    return rect
  }

  /**
   * @param {string} polygonString
   */
  drawPolygon(
    polygonString,
    stroke = '#e1e1e1',
    strokeWidth = 1,
    fill = 'none',
  ) {
    const w = this.w
    const polygon = w.dom.Paper.polygon(polygonString).attr({
      fill,
      stroke,
      'stroke-width': strokeWidth,
    })

    return polygon
  }

  /**
   * @param {number} radius
   * @param {Record<string, any> | null} attrs
   */
  drawCircle(radius, attrs = null) {
    const w = this.w

    if (radius < 0) radius = 0
    const c = w.dom.Paper.circle(radius * 2)
    if (attrs !== null) {
      c.attr(attrs)
    }
    return c
  }

  /** @param {{ d?: string, stroke?: string, strokeWidth?: number, fill: any, fillOpacity?: number, strokeOpacity?: number, classes?: any, strokeLinecap?: any, strokeDashArray?: number }} opts */
  drawPath({
    d = '',
    stroke = '#a8a8a8',
    strokeWidth = 1,
    fill,
    fillOpacity = 1,
    strokeOpacity = 1,
    classes,
    strokeLinecap = null,
    strokeDashArray = 0,
  }) {
    const w = this.w

    if (strokeLinecap === null) {
      strokeLinecap = w.config.stroke.lineCap
    }

    if (d.indexOf('undefined') > -1 || d.indexOf('NaN') > -1) {
      d = `M 0 ${w.layout.gridHeight}`
    }
    const p = w.dom.Paper.path(d).attr({
      fill,
      'fill-opacity': fillOpacity,
      stroke,
      'stroke-opacity': strokeOpacity,
      'stroke-linecap': strokeLinecap,
      'stroke-width': strokeWidth,
      'stroke-dasharray': strokeDashArray,
      class: classes,
    })

    return p
  }

  /**
   * @param {Record<string, any> | null} attrs
   */
  group(attrs = null) {
    const w = this.w
    const g = w.dom.Paper.group()

    if (attrs !== null) {
      g.attr(attrs)
    }
    return g
  }

  /**
   * @param {number} x
   * @param {number} y
   */
  move(x, y) {
    const move = ['M', x, y].join(' ')
    return move
  }

  /**
   * @param {number | null} x
   * @param {number | null} y
   * @param {string | null} hORv
   * @returns {string}
   */
  line(x, y, hORv = null) {
    if (hORv === 'H') return [' H', x].join(' ')
    if (hORv === 'V') return [' V', y].join(' ')
    return [' L', x, y].join(' ')
  }

  /**
   * @param {number} x1
   * @param {number} y1
   * @param {number} x2
   * @param {number} y2
   * @param {number} x
   * @param {number} y
   */
  curve(x1, y1, x2, y2, x, y) {
    const curve = ['C', x1, y1, x2, y2, x, y].join(' ')
    return curve
  }

  /**
   * @param {number} x1
   * @param {number} y1
   * @param {number} x
   * @param {number} y
   */
  quadraticCurve(x1, y1, x, y) {
    const curve = ['Q', x1, y1, x, y].join(' ')
    return curve
  }

  /**
   * @param {number} rx
   * @param {number} ry
   * @param {number} axisRotation
   * @param {number} largeArcFlag
   * @param {number} sweepFlag
   * @param {number} x
   * @param {number} y
   */
  arc(rx, ry, axisRotation, largeArcFlag, sweepFlag, x, y, relative = false) {
    let coord = 'A'
    if (relative) coord = 'a'

    const arc = [
      coord,
      rx,
      ry,
      axisRotation,
      largeArcFlag,
      sweepFlag,
      x,
      y,
    ].join(' ')
    return arc
  }

  /**
   * @memberof Graphics
   * @param {Record<string, any>} opts
   *  i = series's index
   *  realIndex = realIndex is series's actual index when it was drawn time. After several redraws, the iterating "i" may change in loops, but realIndex doesn't
   *  pathFrom = existing pathFrom to animateTo
   *  pathTo = new Path to which d attr will be animated from pathFrom to pathTo
   *  stroke = line Color
   *  strokeWidth = width of path Line
   *  fill = it can be gradient, single color, pattern or image
   *  animationDelay = how much to delay when starting animation (in milliseconds)
   *  dataChangeSpeed = for dynamic animations, when data changes
   *  className = class attribute to add
   *  scrollMorph = this data-change morph is a streaming scroll (see StreamScroll);
   *                defaults the morph easing to linear so the slide is constant-velocity
   * @return {any} svg.js path object
   **/
  renderPaths({
    j,
    realIndex,
    pathFrom,
    pathTo,
    pathToInterp,
    stroke,
    strokeWidth,
    strokeLinecap,
    fill,
    animationDelay,
    initialSpeed,
    dataChangeSpeed,
    className,
    chartType,
    shouldClipToGrid = true,
    bindEventsOnPaths = true,
    drawShadow = true,
    drawMask = null,
    scrollMorph = false,
  }) {
    const w = this.w
    const filters = new Filters(this.w)
    // Pass ctx through so Animations can look up morphTypeChange.getAlgorithm()
    // during the cross-type morph chain. Falls back gracefully when ctx is null.
    const anim = new Animations(this.w, this.ctx ?? undefined)

    const initialAnim = this.w.config.chart.animations.enabled
    const dynamicAnim =
      initialAnim && this.w.config.chart.animations.dynamicAnimation.enabled

    // Fix for paths starting with M 0 0
    // Extra space avoids matching decimal values like "M 0 0.239"
    if (pathFrom && pathFrom.startsWith('M 0 0 ') && pathTo) {
      const moveCommand = pathTo.match(/^M\s+[\d.-]+\s+[\d.-]+/)
      if (moveCommand) {
        pathFrom = pathFrom.replace(/^M\s+0\s+0/, moveCommand[0])
      }
    }

    let d
    // A cross-type morph is not a data change. It arrives through the update
    // path, so `resized` and `dataChanged` are both set, which used to leave it
    // gated on `dynamicAnimation.enabled`, and boxPlot and violin ship with
    // that off by default (an index-based morph on a data change reads as
    // churn). The result was that a type change INTO either of them could not
    // animate at all, however well the engine had paired the marks up.
    const crossTypeMorph = this.ctx?.morphTypeChange?.isActive() === true
    const shouldAnimate = !!(
      (initialAnim && !w.globals.resized) ||
      (dynamicAnim && w.globals.dataChanged && w.globals.shouldAnimate) ||
      (crossTypeMorph && initialAnim && w.globals.shouldAnimate)
    )

    // Draw-mode (initial mount only): line/area/rangeArea/radar render at
    // final path and reveal via stroke-dashoffset or a mask-rect width
    // animation. Default-on whenever `chart.animations.enabled` is true.
    const isDrawableSeries =
      typeof className === 'string' &&
      (className.indexOf('apexcharts-line') > -1 ||
        className.indexOf('apexcharts-area') > -1 ||
        className.indexOf('apexcharts-rangeArea') > -1 ||
        className.indexOf('apexcharts-radar') > -1)
    const useDrawMode = !!(
      initialAnim &&
      !w.globals.resized &&
      !w.globals.dataChanged &&
      isDrawableSeries
    )

    // Large-dataset fast path: morphSVG chains three JS-driven tweens per path,
    // so animating thousands of candlesticks/bars individually ties up the main
    // thread and janks the initial render (and every zoom re-render). Above
    // chart.animations.largeDatasetThreshold we instead render each path at its
    // final position and reveal the whole series with a single GPU-composited
    // opacity fade (reusing delayedElements + apexcharts-element-hidden). Set the
    // threshold to 0 to always animate per-element regardless of dataset size.
    const largeThreshold = w.config.chart.animations.largeDatasetThreshold ?? 0
    const bulkRender = !!(
      shouldAnimate &&
      !useDrawMode &&
      largeThreshold > 0 &&
      w.globals.dataPoints > largeThreshold
    )

    // Candlestick / OHLC / boxPlot data-change reveal. The per-path morph is
    // index-based (candle j → candle j), so on a data change — especially zoom /
    // pan, where the index→x mapping shifts — every candle slides to a new x and
    // reshapes, which reads as chaotic churn. For these types we instead render
    // in the final position and fade the series in (same opacity reveal as the
    // large-dataset bulk path). Initial mount keeps its grow-from-baseline morph
    // (dataChanged is false there). Honors dynamicAnimation.enabled via
    // shouldAnimate — when off, the branch below snaps with no fade.
    const isCandleOrBox =
      chartType === 'candlestick' || chartType === 'boxPlot'
    const fadeOnDataChange = !!(
      isCandleOrBox &&
      shouldAnimate &&
      !useDrawMode &&
      w.globals.dataChanged &&
      // ...but a cross-type morph is a deliberate one-off with marks already
      // paired, so it keeps its tween instead of fading.
      !crossTypeMorph
    )

    // Either path renders at the final position and reveals via one CSS opacity
    // fade rather than a per-path morph.
    const revealViaFade = bulkRender || fadeOnDataChange

    if (shouldAnimate && !useDrawMode && !revealViaFade) {
      d = pathFrom
    } else {
      d = pathTo
      if (!shouldAnimate) {
        w.globals.animationEnded = true
      }
    }

    const strokeDashArrayOpt = w.config.stroke.dashArray
    let strokeDashArray = 0
    if (Array.isArray(strokeDashArrayOpt)) {
      strokeDashArray = strokeDashArrayOpt[realIndex]
    } else {
      strokeDashArray = w.config.stroke.dashArray
    }

    const el = this.drawPath({
      d,
      stroke,
      strokeWidth,
      fill,
      fillOpacity: 1,
      classes: className,
      strokeLinecap,
      strokeDashArray,
    })

    el.attr('index', realIndex)

    if (shouldClipToGrid) {
      if (
        (chartType === 'bar' && !w.globals.isBarHorizontal) ||
        w.globals.comboCharts
      ) {
        el.attr({
          'clip-path': `url(#gridRectBarMask${w.globals.cuid})`,
        })
      } else {
        el.attr({
          'clip-path': `url(#gridRectMask${w.globals.cuid})`,
        })
      }
    }

    if (w.config.chart.dropShadow.enabled && drawShadow) {
      filters.dropShadow(el, w.config.chart.dropShadow, realIndex)
    }

    if (bindEventsOnPaths) {
      el.node.addEventListener('mouseenter', this.pathMouseEnter.bind(this, el))
      el.node.addEventListener('mouseleave', this.pathMouseLeave.bind(this, el))
      el.node.addEventListener('mousedown', this.pathMouseDown.bind(this, el))
    }

    el.attr({
      pathTo,
      pathFrom,
    })

    const defaultAnimateOpts = {
      el,
      j,
      realIndex,
      pathFrom,
      pathTo,
      pathToInterp,
      fill,
      strokeWidth,
      delay: animationDelay,
      scrollMorph,
    }

    if (initialAnim && !w.globals.resized && !w.globals.dataChanged) {
      if (useDrawMode) {
        // The pen-stroke traverses the full gridWidth, so it needs more time
        // than the morph (which just rises from the baseline). Double the
        // configured speed internally so the line/area/radar draw feels
        // unhurried at the default `chart.animations.speed: 800`.
        const drawSpeed = initialSpeed * 2
        const isFill = stroke === 'none' || strokeWidth === 0
        const seriesCount = w.seriesData.series.length
        const isLast = w.globals.comboCharts
          ? true
          : realIndex === seriesCount - 1
        // No per-series stagger for line / area / rangeArea / radar draw mode —
        // all series begin drawing simultaneously. The draw effect's left-to-right
        // motion provides enough visual interest on its own; staggering series
        // makes multi-series charts feel slow and unsynchronised.
        anim.animateDraw(el, {
          realIndex,
          j,
          isFill,
          isLast,
          speed: drawSpeed,
          delay: 0,
          mask: drawMask,
        })
      } else if (!revealViaFade) {
        anim.animatePathsGradually({
          ...defaultAnimateOpts,
          speed: initialSpeed,
        })
      }
    } else {
      // Skip the immediate reveal when we're fading in via revealBulk — it owns
      // the reveal (a single scheduled rAF), so revealing here would pre-empt it.
      if ((w.globals.resized || !w.globals.dataChanged) && !revealViaFade) {
        anim.showDelayedElements()
      }
    }

    // The update-side tween. A cross-type morph rides the same call (its speed
    // is already substituted in dataChangeSpeed by the caller) but must not be
    // gated on `dynamicAnimation.enabled`, which boxPlot and violin turn off:
    // that is a statement about data changes, not about a type change whose
    // marks the engine has explicitly paired.
    const animateOnUpdate =
      shouldAnimate &&
      !revealViaFade &&
      ((w.globals.dataChanged && dynamicAnim) || crossTypeMorph)
    if (animateOnUpdate) {
      anim.animatePathsGradually({
        ...defaultAnimateOpts,
        speed: dataChangeSpeed,
      })
    }

    if (revealViaFade) {
      anim.revealBulk(el)
    }

    return el
  }

  /**
   * @param {string} style
   * @param {number} width
   * @param {number} height
   */
  drawPattern(style, width, height, stroke = '#a8a8a8', strokeWidth = 0) {
    const w = this.w

    /**
     * @param {string} add
     */
    const p = w.dom.Paper.pattern(width, height, (/** @type {any} */ add) => {
      if (style === 'horizontalLines') {
        add
          .line(0, 0, height, 0)
          .stroke({ color: stroke, width: strokeWidth + 1 })
      } else if (style === 'verticalLines') {
        add
          .line(0, 0, 0, width)
          .stroke({ color: stroke, width: strokeWidth + 1 })
      } else if (style === 'slantedLines') {
        add
          .line(0, 0, width, height)
          .stroke({ color: stroke, width: strokeWidth })
      } else if (style === 'squares') {
        add
          .rect(width, height)
          .fill('none')
          .stroke({ color: stroke, width: strokeWidth })
      } else if (style === 'circles') {
        add
          .circle(width)
          .fill('none')
          .stroke({ color: stroke, width: strokeWidth })
      }
    })

    return p
  }

  /**
   * @param {string} style
   * @param {string} gfrom
   * @param {string} gto
   * @param {number} opacityFrom
   * @param {number} opacityTo
   * @param {number | null} [size]
   * @param {number[] | null} stops
   * @param {any[]} colorStops
   * @param {number} [i]
   * @param {boolean} [verticalUserSpace] anchor a vertical gradient to the plot
   *   area rather than to each path's own bounding box. Needed when one series
   *   is drawn as several path elements (nulls split it into segments): with
   *   the default objectBoundingBox each segment resolves the same offset
   *   against a different bbox, so the color transition lands on a different
   *   value in every segment.
   */
  drawGradient(
    style,
    gfrom,
    gto,
    opacityFrom,
    opacityTo,
    size = null,
    stops = null,
    colorStops = [],
    i = 0,
    verticalUserSpace = false,
  ) {
    const w = this.w
    let g

    if (gfrom.length < 9 && gfrom.indexOf('#') === 0) {
      // if the hex contains alpha and is of 9 digit, skip the opacity
      gfrom = Utils.hexToRgba(gfrom, opacityFrom)
    }
    if (gto.length < 9 && gto.indexOf('#') === 0) {
      gto = Utils.hexToRgba(gto, opacityTo)
    }

    let stop1 = 0
    let stop2 = 1
    let stop3 = 1
    /** @type {number | null} */
    let stop4 = null

    if (stops !== null) {
      stop1 = typeof stops[0] !== 'undefined' ? stops[0] / 100 : 0
      stop2 = typeof stops[1] !== 'undefined' ? stops[1] / 100 : 1
      stop3 = typeof stops[2] !== 'undefined' ? stops[2] / 100 : 1
      stop4 = typeof stops[3] !== 'undefined' ? stops[3] / 100 : null
    }

    const radial = !!(
      w.config.chart.type === 'donut' ||
      w.config.chart.type === 'pie' ||
      w.config.chart.type === 'polarArea' ||
      w.config.chart.type === 'bubble'
    )

    if (!colorStops || colorStops.length === 0) {
      /**
       * @param {any} add
       */
      g = w.dom.Paper.gradient(
        radial ? 'radial' : 'linear',
        (/** @type {any} */ add) => {
          add.stop(stop1, gfrom, opacityFrom)
          add.stop(stop2, gto, opacityTo)
          add.stop(stop3, gto, opacityTo)
          if (stop4 !== null) {
            add.stop(stop4, gfrom, opacityFrom)
          }
        },
      )
    } else {
      /**
       * @param {any} add
       */
      g = w.dom.Paper.gradient(
        radial ? 'radial' : 'linear',
        (/** @type {any} */ add) => {
          // colorStops is either flat ([{offset,color},...] shared by all
          // series) or nested ([[...],[...]] per series). When nested but with
          // fewer entries than series, colorStops[i] is undefined; fall back to
          // the first series' stops, NOT the outer nested array (whose elements
          // are arrays, yielding offset="NaN" stop-color="undefined").
          const gradientStops = Array.isArray(colorStops[i])
            ? colorStops[i]
            : Array.isArray(colorStops[0])
              ? colorStops[0] || []
              : colorStops
          /**
           * @param {{offset: number, color: string, opacity: number}} s
           */
          gradientStops.forEach((/** @type {any} */ s) => {
            add.stop(s.offset / 100, s.color, s.opacity)
          })
        },
      )
    }

    if (!radial) {
      if (style === 'vertical') {
        if (verticalUserSpace) {
          // Span the plot area, whose top edge is the y-axis max and bottom
          // edge the y-axis min. Callers must express their offsets over that
          // same axis range.
          g.attr({ gradientUnits: 'userSpaceOnUse' })
          g.from(0, 0).to(0, w.layout.gridHeight)
        } else {
          g.from(0, 0).to(0, 1)
        }
      } else if (style === 'diagonal') {
        g.from(0, 0).to(1, 1)
      } else if (style === 'horizontal') {
        g.from(0, 1).to(1, 1)
      } else if (style === 'diagonal2') {
        g.from(1, 0).to(0, 1)
      }
    } else {
      const offx = w.layout.gridWidth / 2
      const offy = w.layout.gridHeight / 2

      if (w.config.chart.type !== 'bubble') {
        g.attr({
          gradientUnits: 'userSpaceOnUse',
          cx: offx,
          cy: offy,
          r: size,
        })
      } else {
        g.attr({
          cx: 0.5,
          cy: 0.5,
          r: 0.8,
          fx: 0.2,
          fy: 0.2,
        })
      }
    }

    return g
  }

  /** @param {{ text: any, maxWidth: any, fontSize: any, fontFamily?: any }} opts */
  getTextBasedOnMaxWidth({ text, maxWidth, fontSize, fontFamily }) {
    const tRects = this.getTextRects(text, fontSize, fontFamily, '')
    const wordWidth = tRects.width / text.length
    const wordsBasedOnWidth = Math.floor(maxWidth / wordWidth)
    if (maxWidth < tRects.width) {
      return text.slice(0, wordsBasedOnWidth - 3) + '...'
    }
    return text
  }

  /**
   * @param {{ x: any, y: any, text: any, textAnchor?: any, fontSize?: any, fontFamily?: any, fontWeight?: any, foreColor?: any, opacity?: any, maxWidth?: any, cssClass?: string, isPlainText?: boolean, dominantBaseline?: string }} opts
   */
  drawText({
    x,
    y,
    text,
    textAnchor,
    fontSize,
    fontFamily,
    fontWeight,
    foreColor,
    opacity,
    maxWidth,
    cssClass = '',
    isPlainText = true,
    dominantBaseline = 'auto',
  }) {
    const w = this.w

    if (typeof text === 'undefined') text = ''

    let truncatedText = text
    if (!textAnchor) {
      textAnchor = 'start'
    }

    if (!foreColor || !foreColor.length) {
      foreColor = w.config.chart.foreColor
    }
    fontFamily = fontFamily || w.config.chart.fontFamily
    fontSize = fontSize || '11px'
    fontWeight = fontWeight || 'regular'

    const commonProps = {
      maxWidth,
      fontSize,
      fontFamily,
    }
    let elText
    if (Array.isArray(text)) {
      /**
       * @param {any} add
       */
      elText = w.dom.Paper.text((/** @type {any} */ add) => {
        for (let i = 0; i < text.length; i++) {
          truncatedText = text[i]
          if (maxWidth) {
            truncatedText = this.getTextBasedOnMaxWidth({
              text: text[i],
              ...commonProps,
            })
          }
          i === 0
            ? add.tspan(truncatedText)
            : add.tspan(truncatedText).newLine()
        }
      })
    } else {
      if (maxWidth) {
        truncatedText = this.getTextBasedOnMaxWidth({
          text,
          ...commonProps,
        })
      }
      elText = isPlainText
        ? w.dom.Paper.plain(text)
        : /**
           * @param {any} add
           */
          w.dom.Paper.text((/** @type {any} */ add) => add.tspan(truncatedText))
    }

    elText.attr({
      x,
      y,
      'text-anchor': textAnchor,
      'dominant-baseline': dominantBaseline,
      'font-size': fontSize,
      'font-family': fontFamily,
      'font-weight': fontWeight,
      fill: foreColor,
      class: 'apexcharts-text ' + cssClass,
    })

    elText.node.style.fontFamily = fontFamily
    elText.node.style.opacity = opacity

    return elText
  }

  /**
   * @param {number} x
   * @param {number} y
   * @param {string} type
   * @param {number} size
   */
  getMarkerPath(x, y, type, size) {
    // Per-shape size adjustments, tuned empirically so every marker shape reads
    // as roughly equal visual weight at the same nominal `size`. Kept as the
    // original divide/multiply operations (not pre-divided) so the emitted path
    // strings stay byte-identical to before this was named.
    const CROSS_SHRINK = 1.4
    const PLUS_SHRINK = 1.12
    const STAR_GROW = 1.15
    const SPARKLE_SHRINK = 1.1 // applied on top of STAR_GROW for the 4-point sparkle
    const SQUARE_SHRINK = 1.125 // also used by 'rect'
    const DIAMOND_GROW = 1.05
    const LINE_SHRINK = 1.1
    const CIRCLE_DIAMETER = 2 // radius -> diameter for the SVG arc command
    let d = ''
    switch (type) {
      case 'cross':
        size = size / CROSS_SHRINK
        d = `M ${x - size} ${y - size} L ${x + size} ${y + size}  M ${
          x - size
        } ${y + size} L ${x + size} ${y - size}`
        break
      case 'plus':
        size = size / PLUS_SHRINK
        d = `M ${x - size} ${y} L ${x + size} ${y}  M ${x} ${y - size} L ${x} ${
          y + size
        }`
        break
      case 'star':
      case 'sparkle': {
        let points = 5
        size = size * STAR_GROW
        if (type === 'sparkle') {
          size = size / SPARKLE_SHRINK
          points = 4
        }
        const step = Math.PI / points

        for (let i = 0; i <= 2 * points; i++) {
          const angle = i * step
          const radius = i % 2 === 0 ? size : size / 2
          const xPos = x + radius * Math.sin(angle)
          const yPos = y - radius * Math.cos(angle)

          d += (i === 0 ? 'M' : 'L') + xPos + ',' + yPos
        }
        d += 'Z'
        break
      }
      case 'triangle':
        d = `M ${x} ${y - size} 
             L ${x + size} ${y + size} 
             L ${x - size} ${y + size} 
             Z`
        break
      case 'square':
      case 'rect':
        size = size / SQUARE_SHRINK
        d = `M ${x - size} ${y - size} 
           L ${x + size} ${y - size} 
           L ${x + size} ${y + size} 
           L ${x - size} ${y + size} 
           Z`
        break
      case 'diamond':
        size = size * DIAMOND_GROW
        d = `M ${x} ${y - size} 
             L ${x + size} ${y} 
             L ${x} ${y + size} 
             L ${x - size} ${y} 
            Z`
        break
      case 'line':
        size = size / LINE_SHRINK
        d = `M ${x - size} ${y} 
           L ${x + size} ${y}`
        break
      case 'circle':
      default:
        size = size * CIRCLE_DIAMETER
        d = `M ${x}, ${y} 
           m -${size / 2}, 0 
           a ${size / 2},${size / 2} 0 1,0 ${size},0 
           a ${size / 2},${size / 2} 0 1,0 -${size},0`
        break
    }
    return d
  }

  /**
   * @param {number} x - The x-coordinate of the marker
   * @param {number} y - The y-coordinate of the marker
   * @param {string} type - Marker shape type
   * @param {number} size - The size of the marker
   * @param {Record<string, any>} opts - The options for the marker
   * @returns {any} The created marker.
   */
  drawMarkerShape(x, y, type, size, opts) {
    const path = this.drawPath({
      d: this.getMarkerPath(x, y, type, size),
      stroke: opts.pointStrokeColor,
      strokeDashArray: opts.pointStrokeDashArray,
      strokeWidth: opts.pointStrokeWidth,
      fill: opts.pointFillColor,
      fillOpacity: opts.pointFillOpacity,
      strokeOpacity: opts.pointStrokeOpacity,
    })

    path.attr({
      cx: x,
      cy: y,
      shape: opts.shape,
      class: opts.class ? opts.class : '',
    })

    return path
  }

  /**
   * @param {number} x
   * @param {number} y
   * @param {Record<string, any>} opts
   */
  drawMarker(x, y, opts) {
    x = x || 0
    let size = opts.pSize || 0

    if (!Utils.isNumber(y)) {
      size = 0
      y = 0
    }

    return this.drawMarkerShape(x, y, opts?.shape, size, {
      ...opts,
      ...(opts.shape === 'line' ||
      opts.shape === 'plus' ||
      opts.shape === 'cross'
        ? {
            pointStrokeColor: opts.pointFillColor,
            pointStrokeOpacity: opts.pointFillOpacity,
          }
        : {}),
    })
  }

  /**
   * @param {any} path
   * @param {Event | null} [e]
   */
  pathMouseEnter(path, e) {
    const w = this.w
    const filters = new Filters(this.w)

    const i = parseInt(path.node.getAttribute('index') ?? '', 10)
    const j = parseInt(path.node.getAttribute('j') ?? '', 10)
    if (isNaN(i) || isNaN(j)) return

    if (typeof w.config.chart.events.dataPointMouseEnter === 'function') {
      w.config.chart.events.dataPointMouseEnter(e, this.ctx, {
        seriesIndex: i,
        dataPointIndex: j,
        w,
      })
    }
    Graphics._fireEvent(w, 'dataPointMouseEnter', [
      e,
      this.ctx,
      { seriesIndex: i, dataPointIndex: j, w },
    ])

    if (w.config.states.active.filter.type !== 'none') {
      if (path.node.getAttribute('selected') === 'true') {
        return
      }
    }

    // A pie / donut slice shows hover as an outline band traced outside its rim
    // (Pie.showHoverOutline), and keeps its own colour while hovered.
    if (Filters.hoverOutlineOwnsHoverState(w)) return

    if (w.config.states.hover.filter.type !== 'none') {
      if (!w.interact.isTouchDevice) {
        const hoverFilter = w.config.states.hover.filter
        filters.applyFilter(path, i, hoverFilter.type, hoverFilter.value)
      }
    }
  }

  /**
   * @param {any} path
   * @param {Event | null} [e]
   */
  pathMouseLeave(path, e) {
    const w = this.w
    const filters = new Filters(this.w)

    const i = parseInt(path.node.getAttribute('index') ?? '', 10)
    const j = parseInt(path.node.getAttribute('j') ?? '', 10)
    if (isNaN(i) || isNaN(j)) return

    if (typeof w.config.chart.events.dataPointMouseLeave === 'function') {
      w.config.chart.events.dataPointMouseLeave(e, this.ctx, {
        seriesIndex: i,
        dataPointIndex: j,
        w,
      })
    }
    Graphics._fireEvent(w, 'dataPointMouseLeave', [
      e,
      this.ctx,
      { seriesIndex: i, dataPointIndex: j, w },
    ])

    if (w.config.states.active.filter.type !== 'none') {
      if (path.node.getAttribute('selected') === 'true') {
        return
      }
    }

    // Nothing was filtered on enter for a slice with a hover outline, so there
    // is nothing to restore here either (the band fades out on its own).
    if (Filters.hoverOutlineOwnsHoverState(w)) return

    if (w.config.states.hover.filter.type !== 'none') {
      filters.getDefaultFilter(path, i)
    }
  }

  /**
   * Clear every selected data point (single-select mode): reset the selection
   * array and restore the default filter on all series path/circle/rect nodes.
   * NOTE: preserves the original behavior of passing the clicked series index
   * `i` to getDefaultFilter for every element (not each element's own index).
   * @param {any} filters @param {number} i
   */
  _clearAllDataPointSelections(filters, i) {
    const w = this.w
    w.interact.selectedDataPoints = []
    const elPaths = w.dom.Paper.find(
      '.apexcharts-series path:not(.apexcharts-decoration-element)',
    )
    const elCircles = w.dom.Paper.find(
      '.apexcharts-series circle:not(.apexcharts-decoration-element), .apexcharts-series rect:not(.apexcharts-decoration-element)',
    )
    /** @param {any[]} els */
    const deSelect = (els) => {
      Array.prototype.forEach.call(els, (/** @type {any} */ el) => {
        el.node.setAttribute('selected', 'false')
        filters.getDefaultFilter(el, i)
      })
    }
    deSelect(elPaths)
    deSelect(elCircles)
  }

  /**
   * Toggle the selected state of a single data point. Mutates
   * `w.interact.selectedDataPoints` and the path's `selected` attribute,
   * honoring single- vs multi-select mode. Returns the new selected flag.
   * @param {any} path @param {any} filters @param {number} i @param {number} j
   * @returns {'true' | 'false'}
   */
  _togglePointSelection(path, filters, i, j) {
    const w = this.w
    if (path.node.getAttribute('selected') === 'true') {
      path.node.setAttribute('selected', 'false')

      const index = w.interact.selectedDataPoints[i].indexOf(j)
      if (index > -1) {
        w.interact.selectedDataPoints[i].splice(index, 1)
      }
      return 'false'
    }

    if (
      !w.config.states.active.allowMultipleDataPointsSelection &&
      w.interact.selectedDataPoints.length > 0
    ) {
      this._clearAllDataPointSelections(filters, i)
    }

    path.node.setAttribute('selected', 'true')

    if (typeof w.interact.selectedDataPoints[i] === 'undefined') {
      w.interact.selectedDataPoints[i] = []
    }
    w.interact.selectedDataPoints[i].push(j)
    return 'true'
  }

  /**
   * Apply the active / hover / default state filter after a selection toggle,
   * matching the original inline branching.
   * @param {any} path @param {any} filters @param {number} i
   * @param {'true' | 'false'} selected
   */
  _applyPointSelectionFilter(path, filters, i, selected) {
    const w = this.w

    // A clicked pie / donut slice shows selection by sliding out of the pie
    // (Pie.offsetSlice), so it must not be darkened on top of that. Returning
    // early also leaves any hover visual alone, which is what we want while
    // the pointer is still over the slice being toggled.
    if (Filters.sliceOffsetOwnsActiveState(w)) return

    if (selected === 'true') {
      const activeFilter = w.config.states.active.filter
      if (activeFilter !== 'none') {
        filters.applyFilter(path, i, activeFilter.type, activeFilter.value)
      } else {
        // Reapply the hover filter in case it was removed by `deselect`when there is no active filter and it is not a touch device
        if (w.config.states.hover.filter !== 'none') {
          if (!w.interact.isTouchDevice) {
            const hoverFilter = w.config.states.hover.filter
            filters.applyFilter(path, i, hoverFilter.type, hoverFilter.value)
          }
        }
      }
    } else {
      // If the item was deselected, apply hover state filter if it is not a touch device
      if (w.config.states.active.filter.type !== 'none') {
        if (
          w.config.states.hover.filter.type !== 'none' &&
          !w.interact.isTouchDevice
        ) {
          const hoverFilter = w.config.states.hover.filter
          filters.applyFilter(path, i, hoverFilter.type, hoverFilter.value)
        } else {
          filters.getDefaultFilter(path, i)
        }
      }
    }
  }

  /**
   * @param {any} path
   * @param {Event | null} e
   */
  pathMouseDown(path, e) {
    const w = this.w
    const filters = new Filters(this.w)

    const i = parseInt(path.node.getAttribute('index') ?? '', 10)
    const j = parseInt(path.node.getAttribute('j') ?? '', 10)
    if (isNaN(i) || isNaN(j)) return

    // Crossfilter / Linked Views click-source: when any link mode is active the
    // coordinator owns the visual state (filter mode dims the unselected buckets
    // itself; highlight mode dims via the brush range). Skip the point-selection
    // bookkeeping and the sticky active/darken filter so a click never leaves a
    // darkened bar competing for attention during filtering. This mirrors
    // LinkedViews._mode() !== 'off'. The dataPointSelection events below still
    // fire (the crossfilter engine listens to them in filter mode, and any user
    // handler still runs).
    const link = w.config.chart.link
    const crossfilterClick = !!(
      link && (typeof link.dimension === 'function' || link.enabled)
    )

    if (!crossfilterClick) {
      const selected = this._togglePointSelection(path, filters, i, j)
      this._applyPointSelectionFilter(path, filters, i, selected)
    }

    if (typeof w.config.chart.events.dataPointSelection === 'function') {
      w.config.chart.events.dataPointSelection(e, this.ctx, {
        selectedDataPoints: w.interact.selectedDataPoints,
        seriesIndex: i,
        dataPointIndex: j,
        w,
      })
    }

    if (e) {
      Graphics._fireEvent(w, 'dataPointSelection', [
        e,
        this.ctx,
        {
          selectedDataPoints: w.interact.selectedDataPoints,
          seriesIndex: i,
          dataPointIndex: j,
          w,
        },
      ])
    }
  }

  /**
   * @param {any} el
   * @returns {{ x: number, y: number }}
   */
  rotateAroundCenter(el) {
    let coord = /** @type {any} */ ({})
    if (el && typeof el.getBBox === 'function') {
      coord = el.getBBox()
    }
    const x = coord.x + coord.width / 2
    const y = coord.y + coord.height / 2

    return {
      x,
      y,
    }
  }

  /**
   * Sets up event delegation on a parent group element.
   * Uses mouseover/mouseout (which bubble) to simulate mouseenter/mouseleave
   * on matching child elements, reducing per-element listener overhead.
   * @param {any} parentGroup
   * @param {string} targetSelector
   */
  setupEventDelegation(parentGroup, targetSelector) {
    /** @type {any} */
    let currentHovered = null

    /**
     * @param {Event} e
     */
    parentGroup.node.addEventListener('mouseover', (/** @type {any} */ e) => {
      const targetNode = Graphics._findDelegateTarget(
        e.target,
        parentGroup.node,
        targetSelector,
      )
      if (!targetNode || targetNode === currentHovered) return

      if (currentHovered && /** @type {any} */ (currentHovered).instance) {
        this.pathMouseLeave(/** @type {any} */ (currentHovered).instance, e)
      }
      currentHovered = targetNode
      if (targetNode.instance) {
        this.pathMouseEnter(targetNode.instance, e)
      }
    })

    /**
     * @param {Event} e
     */
    parentGroup.node.addEventListener('mouseout', (/** @type {any} */ e) => {
      if (!currentHovered) return
      const relatedNode = e.relatedTarget
        ? Graphics._findDelegateTarget(
            e.relatedTarget,
            parentGroup.node,
            targetSelector,
          )
        : null
      if (relatedNode !== currentHovered) {
        if (currentHovered && /** @type {any} */ (currentHovered).instance) {
          this.pathMouseLeave(/** @type {any} */ (currentHovered).instance, e)
        }
        currentHovered = null
      }
    })

    /**
     * @param {Event} e
     */
    parentGroup.node.addEventListener('mousedown', (/** @type {any} */ e) => {
      const targetNode = Graphics._findDelegateTarget(
        e.target,
        parentGroup.node,
        targetSelector,
      )
      if (targetNode && targetNode.instance) {
        this.pathMouseDown(targetNode.instance, e)
      }
    })
  }

  // Fire a named event from w.globals.events without requiring a ctx reference.
  // Mirrors Events.fireEvent() but reads the registry directly from w so that
  // pathMouseEnter/Leave/Down work even when this.ctx is null (Graphics instances
  // created without a ctx arg for drawing-only use cases).
  /**
   * @param {import('../types/internal').ChartStateW} w
   * @param {string} name
   * @param {any[]} args
   */
  static _fireEvent(w, name, args) {
    const evs = w.globals.events
    if (!evs || !Object.prototype.hasOwnProperty.call(evs, name)) return
    const handlers = /** @type {Record<string,any>} */ (evs)[name]
    for (let i = 0; i < handlers.length; i++) {
      handlers[i].apply(null, args)
    }
  }

  /**
   * @param {any} node
   * @param {Record<string, any>} boundary
   * @param {string} selector
   */
  static _findDelegateTarget(node, boundary, selector) {
    while (node && node !== boundary && node !== document) {
      if (node.matches && node.matches(selector)) return node
      node = node.parentNode
    }
    return null
  }

  /**
   * @param {any} el
   * @param {Record<string, any>} attrs
   */
  static setAttrs(el, attrs) {
    for (const key in attrs) {
      if (Object.prototype.hasOwnProperty.call(attrs, key)) {
        el.setAttribute(key, attrs[key])
      }
    }
  }

  /**
   * @param {string} text
   * @param {string} fontSize
   * @param {string | null | undefined} [fontFamily]
   * @param {string} [transform]
   * @param {boolean} [useBBox]
   * @param {string | number} [fontWeight] weight to measure at. Omit and the
   *   measurement is taken at 'regular' (drawText's default), which is only
   *   correct for text that also RENDERS at regular. Bolder text is wider, so
   *   measuring a bold label at regular under-reports its width and any
   *   fit/overflow decision made from it comes up short.
   * @returns {{ width: number, height: number }}
   */
  getTextRects(
    text,
    fontSize,
    fontFamily,
    transform,
    useBBox = true,
    fontWeight,
  ) {
    const w = this.w

    // cache text measurements to avoid repeated DOM create/measure/remove cycles
    // Use \0 (null byte) as separator — cannot appear in font families or CSS transforms
    const cacheKey = [
      text,
      fontSize,
      fontFamily,
      transform,
      useBBox,
      fontWeight,
    ].join('\0')
    const cache = w.globals.textRectsCache
    if (cache && cache.has(cacheKey)) {
      return /** @type {{ width: number, height: number }} */ (
        cache.get(cacheKey)
      )
    }

    const virtualText = this.drawText({
      x: -200,
      y: -200,
      text,
      textAnchor: 'start',
      fontSize,
      fontFamily,
      fontWeight,
      foreColor: '#fff',
      opacity: 0,
    })

    if (transform) {
      virtualText.attr('transform', transform)
    }
    w.dom.Paper.add(virtualText)

    let rect = virtualText.bbox()
    // `bboxY` is the bbox top in the same coord system as the y attribute we
    // drew at (-200). We use it to compute how far the rendered glyphs
    // extend above the baseline — needed for accurate vertical centering
    // (see `centerOffset`).
    const bboxY = rect.y
    if (!useBBox) {
      rect = virtualText.node.getBoundingClientRect()
    }

    virtualText.remove()

    const result = {
      width: rect.width,
      height: rect.height,
      // Offset from the text element's `y` (alphabetic baseline) to the
      // bbox vertical center. For most fonts this is NEGATIVE (bbox center
      // sits above the baseline because the ascender is taller than the
      // descender). Use as: `text_y = desired_visual_center_y - centerOffset`.
      // Only populated when useBBox=true (the default).
      centerOffset: useBBox ? bboxY + rect.height / 2 - -200 : 0,
    }

    if (cache) {
      cache.set(cacheKey, result)
    }

    return result
  }

  /**
   * append ... to long text
   * http://stackoverflow.com/questions/9241315/trimming-text-to-a-given-pixel-width-in-svg
   * @memberof Graphics
   * @param {Record<string, any>} textObj
   * @param {string} textString
   * @param {number} width
   **/
  placeTextWithEllipsis(textObj, textString, width) {
    if (typeof textObj.getComputedTextLength !== 'function') return
    textObj.textContent = textString
    if (textString.length > 0) {
      // ellipsis is needed
      if (textObj.getComputedTextLength() >= width / 1.1) {
        for (let x = textString.length - 3; x > 0; x -= 3) {
          if (textObj.getSubStringLength(0, x) <= width / 1.1) {
            textObj.textContent = textString.substring(0, x) + '...'
            return
          }
        }
        textObj.textContent = '.' // can't place at all
      }
    }
  }
}

export default Graphics