// @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)

      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
   * @return {any} svg.js path object
   **/
  renderPaths({
    j,
    realIndex,
    pathFrom,
    pathTo,
    stroke,
    strokeWidth,
    strokeLinecap,
    fill,
    animationDelay,
    initialSpeed,
    dataChangeSpeed,
    className,
    chartType,
    shouldClipToGrid = true,
    bindEventsOnPaths = true,
    drawShadow = true,
  }) {
    const w = this.w
    const filters = new Filters(this.w)
    const anim = new Animations(this.w, /** @type {any} */ (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
    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
    const shouldAnimate = !!(
      (initialAnim && !w.globals.resized) ||
      (dynamicAnim && w.globals.dataChanged && w.globals.shouldAnimate)
    )

    if (shouldAnimate) {
      d = pathFrom
    } else {
      d = pathTo
      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,
      fill,
      strokeWidth,
      delay: animationDelay,
    }

    if (initialAnim && !w.globals.resized && !w.globals.dataChanged) {
      anim.animatePathsGradually({
        ...defaultAnimateOpts,
        speed: initialSpeed,
      })
    } else {
      if (w.globals.resized || !w.globals.dataChanged) {
        anim.showDelayedElements()
      }
    }

    if (w.globals.dataChanged && dynamicAnim && shouldAnimate) {
      anim.animatePathsGradually({
        ...defaultAnimateOpts,
        speed: dataChangeSpeed,
      })
    }

    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
   */
  drawGradient(
    style,
    gfrom,
    gto,
    opacityFrom,
    opacityTo,
    size = null,
    stops = null,
    colorStops = [],
    i = 0,
  ) {
    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) => {
          const gradientStops = Array.isArray(colorStops[i])
            ? colorStops[i]
            : 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') {
        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) {
    let d = ''
    switch (type) {
      case 'cross':
        size = size / 1.4
        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 / 1.12
        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 * 1.15
        if (type === 'sparkle') {
          size = size / 1.1
          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 / 1.125
        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 * 1.05
        d = `M ${x} ${y - size} 
             L ${x + size} ${y} 
             L ${x} ${y + size} 
             L ${x - size} ${y} 
            Z`
        break
      case 'line':
        size = size / 1.1
        d = `M ${x - size} ${y} 
           L ${x + size} ${y}`
        break
      case 'circle':
      default:
        size = size * 2
        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
      }
    }

    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)
      }
    }
  }

  /**
   * @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
      }
    }

    if (w.config.states.hover.filter.type !== 'none') {
      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

    let selected = 'false'
    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)
      }
    } else {
      if (
        !w.config.states.active.allowMultipleDataPointsSelection &&
        w.interact.selectedDataPoints.length > 0
      ) {
        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) => {
          /**
           * @param {any} el
           */
          Array.prototype.forEach.call(els, (el) => {
            el.node.setAttribute('selected', 'false')
            filters.getDefaultFilter(el, i)
          })
        }
        deSelect(elPaths)
        deSelect(elCircles)
      }

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

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

    if (selected === 'true') {
      const activeFilter = w.config.states.active.filter
      if (activeFilter !== 'none') {
        filters.applyFilter(path, i, activeFilter.type)
      } 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)
          }
        }
      }
    } 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)
        } else {
          filters.getDefaultFilter(path, i)
        }
      }
    }

    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]
   * @returns {{ width: number, height: number }}
   */
  getTextRects(text, fontSize, fontFamily, transform, useBBox = true) {
    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].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,
      foreColor: '#fff',
      opacity: 0,
    })

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

    let rect = virtualText.bbox()
    if (!useBBox) {
      rect = virtualText.node.getBoundingClientRect()
    }

    virtualText.remove()

    const result = {
      width: rect.width,
      height: rect.height,
    }

    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
