UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,401 lines (1,263 loc) 38 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) 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