UNPKG

apexcharts

Version:

A JavaScript Chart Library

619 lines (516 loc) 19.5 kB
// @ts-check import CoreUtils from '../CoreUtils' import Dimensions from '../dimensions/Dimensions' import Graphics from '../Graphics' import Series from '../Series' import Utils from '../../utils/Utils' import Helpers from './Helpers' import Markers from '../Markers' import { Environment } from '../../utils/Environment.js' import { BrowserAPIs } from '../../ssr/BrowserAPIs.js' /** * ApexCharts Legend Class to draw legend. * * @module Legend **/ class Legend { /** * @param {import('../../types/internal').ChartStateW} w * @param {import('../../types/internal').ChartContext} ctx */ constructor(w, ctx) { this.w = w this.ctx = ctx // needed: fires events, passes chart instance to user callbacks // Injected callbacks used by LegendHelpers (avoids lgCtx.ctx.pie / lgCtx.ctx.updateHelpers) this.printDataLabelsInner = (/** @type {any[]} */ ...a) => ctx.pie?.printDataLabelsInner(/** @type {any[]} */ ...a) this.updateSeries = (/** @type {any[]} */ ...a) => ctx.updateHelpers._updateSeries(/** @type {any[]} */ ...a) this.onLegendClick = this.onLegendClick.bind(this) this.onLegendHovered = this.onLegendHovered.bind(this) this.isBarsDistributed = this.w.config.chart.type === 'bar' && this.w.config.plotOptions.bar.distributed && this.w.config.series.length === 1 this.legendHelpers = new Helpers(this) } init() { const w = this.w const gl = w.globals const cnf = w.config const showLegendAlways = (cnf.legend.showForSingleSeries && this.w.seriesData.series.length === 1) || this.isBarsDistributed || this.w.seriesData.series.length > 1 this.legendHelpers.appendToForeignObject() if ((showLegendAlways || !gl.axisCharts) && cnf.legend.show) { const elLegendWrap = /** @type {HTMLElement} */ (w.dom.elLegendWrap) while (elLegendWrap.firstChild) { elLegendWrap.removeChild(elLegendWrap.firstChild) } this.drawLegends() if (cnf.legend.position === 'bottom' || cnf.legend.position === 'top') { this.legendAlignHorizontal() } else if ( cnf.legend.position === 'right' || cnf.legend.position === 'left' ) { this.legendAlignVertical() } } } createLegendMarker(/** @type {any} */ { i, fillcolor }) { const w = this.w const elMarker = BrowserAPIs.createElement('span') elMarker.classList.add('apexcharts-legend-marker') const mShape = w.config.legend.markers.shape || w.config.markers.shape let shape = mShape if (Array.isArray(mShape)) { shape = mShape[i] } const mSize = Array.isArray(w.config.legend.markers.size) ? parseFloat(w.config.legend.markers.size[i]) : parseFloat(w.config.legend.markers.size) const mOffsetX = Array.isArray(w.config.legend.markers.offsetX) ? parseFloat(w.config.legend.markers.offsetX[i]) : parseFloat(w.config.legend.markers.offsetX) const mOffsetY = Array.isArray(w.config.legend.markers.offsetY) ? parseFloat(w.config.legend.markers.offsetY[i]) : parseFloat(w.config.legend.markers.offsetY) const mBorderWidth = Array.isArray(w.config.legend.markers.strokeWidth) ? parseFloat(w.config.legend.markers.strokeWidth[i]) : parseFloat(w.config.legend.markers.strokeWidth) const mStyle = elMarker.style mStyle.height = (mSize + mBorderWidth) * 2 + 'px' mStyle.width = (mSize + mBorderWidth) * 2 + 'px' mStyle.left = mOffsetX + 'px' mStyle.top = mOffsetY + 'px' if (w.config.legend.markers.customHTML) { mStyle.background = 'transparent' mStyle.color = fillcolor[i] if (Array.isArray(w.config.legend.markers.customHTML)) { if (w.config.legend.markers.customHTML[i]) { elMarker.innerHTML = w.config.legend.markers.customHTML[i]() } } else { elMarker.innerHTML = w.config.legend.markers.customHTML() } } else { const markers = new Markers(this.ctx.w, this.ctx) const markerConfig = markers.getMarkerConfig({ cssClass: `apexcharts-legend-marker apexcharts-marker apexcharts-marker-${shape}`, seriesIndex: i, strokeWidth: mBorderWidth, size: mSize, }) const SVGLib = Environment.isBrowser() ? /** @type {any} */ (window).SVG : /** @type {any} */ (global).SVG const SVGMarker = SVGLib().addTo(elMarker).size('100%', '100%') const marker = new Graphics(this.w).drawMarker(0, 0, { ...markerConfig, pointFillColor: Array.isArray(fillcolor) ? fillcolor[i] : markerConfig.pointFillColor, shape, }) const shapesEls = w.dom.Paper.find( '.apexcharts-legend-marker.apexcharts-marker', ) /** * @param {any} shapeEl */ shapesEls.forEach((/** @type {any} */ shapeEl) => { if (shapeEl.node.classList.contains('apexcharts-marker-triangle')) { shapeEl.node.style.transform = 'translate(50%, 45%)' } else { shapeEl.node.style.transform = 'translate(50%, 50%)' } }) SVGMarker.add(marker) } return elMarker } drawLegends() { const me = this const w = this.w const elLegendWrap = /** @type {HTMLElement} */ (w.dom.elLegendWrap) const fontFamily = w.config.legend.fontFamily let legendNames = w.seriesData.seriesNames let fillcolor = w.config.legend.markers.fillColors ? w.config.legend.markers.fillColors.slice() : w.globals.colors.slice() if (w.config.chart.type === 'heatmap') { const ranges = w.config.plotOptions.heatmap.colorScale.ranges legendNames = ranges.map((/** @type {any} */ colorScale) => { return colorScale.name ? colorScale.name : colorScale.from + ' - ' + colorScale.to }) fillcolor = ranges.map((/** @type {any} */ color) => color.color) } else if (this.isBarsDistributed) { legendNames = w.labelData.labels.slice() } if (w.config.legend.customLegendItems.length) { legendNames = w.config.legend.customLegendItems } const legendFormatter = w.formatters.legendFormatter const isLegendInversed = w.config.legend.inverseOrder /** @type {any[]} */ const legendGroups = [] if ( w.labelData.seriesGroups.length > 1 && w.config.legend.clusterGroupedSeries ) { /** * @param {any} _ * @param {number} gi */ w.labelData.seriesGroups.forEach((_, gi) => { legendGroups[gi] = BrowserAPIs.createElement('div') legendGroups[gi].classList.add( 'apexcharts-legend-group', `apexcharts-legend-group-${gi}`, ) if (w.config.legend.clusterGroupedSeriesOrientation === 'horizontal') { elLegendWrap.classList.add('apexcharts-legend-group-horizontal') } else { legendGroups[gi].classList.add('apexcharts-legend-group-vertical') } }) } for ( let i = isLegendInversed ? legendNames.length - 1 : 0; isLegendInversed ? i >= 0 : i <= legendNames.length - 1; isLegendInversed ? i-- : i++ ) { const text = legendFormatter(legendNames[i], { seriesIndex: i, w }) let collapsedSeries = false let ancillaryCollapsedSeries = false if (w.globals.collapsedSeries.length > 0) { for (let c = 0; c < w.globals.collapsedSeries.length; c++) { if (w.globals.collapsedSeries[c].index === i) { collapsedSeries = true } } } if (w.globals.ancillaryCollapsedSeriesIndices.length > 0) { for ( let c = 0; c < w.globals.ancillaryCollapsedSeriesIndices.length; c++ ) { if (w.globals.ancillaryCollapsedSeriesIndices[c] === i) { ancillaryCollapsedSeries = true } } } const elMarker = this.createLegendMarker({ i, fillcolor }) Graphics.setAttrs(elMarker, { rel: i + 1, 'data:collapsed': collapsedSeries || ancillaryCollapsedSeries, }) if (collapsedSeries || ancillaryCollapsedSeries) { elMarker.classList.add('apexcharts-inactive-legend') } const elLegend = BrowserAPIs.createElement('div') // accessibility attributes if ( w.config.chart.accessibility.enabled && w.config.chart.accessibility.keyboard.enabled ) { elLegend.setAttribute('role', 'button') elLegend.setAttribute('tabindex', '0') // Use formatted legend text (handle both string and array) const seriesName = Array.isArray(text) ? text.join(' ') : text const isCollapsed = collapsedSeries || ancillaryCollapsedSeries const statusText = isCollapsed ? 'hidden' : 'visible' elLegend.setAttribute( 'aria-label', `${seriesName}, ${statusText}. Press Enter or Space to toggle.`, ) elLegend.setAttribute('aria-pressed', isCollapsed ? 'true' : 'false') } const elLegendText = BrowserAPIs.createElement('span') elLegendText.classList.add('apexcharts-legend-text') elLegendText.innerHTML = Array.isArray(text) ? text.join(' ') : text let textColor = w.config.legend.labels.useSeriesColors ? w.globals.colors[i] : Array.isArray(w.config.legend.labels.colors) ? w.config.legend.labels.colors?.[i] : w.config.legend.labels.colors if (!textColor) { textColor = w.config.chart.foreColor } elLegendText.style.color = textColor elLegendText.style.fontSize = w.config.legend.fontSize elLegendText.style.fontWeight = w.config.legend.fontWeight elLegendText.style.fontFamily = fontFamily || w.config.chart.fontFamily Graphics.setAttrs(elLegendText, { rel: i + 1, i, 'data:default-text': encodeURIComponent(text), 'data:collapsed': collapsedSeries || ancillaryCollapsedSeries, }) elLegend.appendChild(elMarker) elLegend.appendChild(elLegendText) const coreUtils = new CoreUtils(this.w) if (!w.config.legend.showForZeroSeries) { const total = coreUtils.getSeriesTotalByIndex(i) if ( total === 0 && coreUtils.seriesHaveSameValues(i) && !coreUtils.isSeriesNull(i) && w.globals.collapsedSeriesIndices.indexOf(i) === -1 && w.globals.ancillaryCollapsedSeriesIndices.indexOf(i) === -1 ) { elLegend.classList.add('apexcharts-hidden-zero-series') } } if (!w.config.legend.showForNullSeries) { if ( coreUtils.isSeriesNull(i) && w.globals.collapsedSeriesIndices.indexOf(i) === -1 && w.globals.ancillaryCollapsedSeriesIndices.indexOf(i) === -1 ) { elLegend.classList.add('apexcharts-hidden-null-series') } } if (legendGroups.length) { /** * @param {string[]} group * @param {number} gi */ w.labelData.seriesGroups.forEach((group, gi) => { if ( group.includes(/** @type {Record<string,any>} */ (w.config.series[i])?.name ?? '') ) { elLegendWrap.appendChild(legendGroups[gi]) legendGroups[gi].appendChild(elLegend) } }) } else { elLegendWrap.appendChild(elLegend) } elLegendWrap.classList.add( `apexcharts-align-${w.config.legend.horizontalAlign}`, ) elLegendWrap.classList.add( 'apx-legend-position-' + w.config.legend.position, ) elLegend.classList.add('apexcharts-legend-series') elLegend.style.margin = `${w.config.legend.itemMargin.vertical}px ${w.config.legend.itemMargin.horizontal}px` elLegendWrap.style.width = w.config.legend.width ? w.config.legend.width + 'px' : '' elLegendWrap.style.height = w.config.legend.height ? w.config.legend.height + 'px' : '' Graphics.setAttrs(elLegend, { rel: i + 1, seriesName: Utils.escapeString(legendNames[i]), 'data:collapsed': collapsedSeries || ancillaryCollapsedSeries, }) if (collapsedSeries || ancillaryCollapsedSeries) { elLegend.classList.add('apexcharts-inactive-legend') } if (!w.config.legend.onItemClick.toggleDataSeries) { elLegend.classList.add('apexcharts-no-click') } } w.dom.elWrap.addEventListener('click', me.onLegendClick, true) if ( w.config.legend.onItemHover.highlightDataSeries && w.config.legend.customLegendItems.length === 0 ) { w.dom.elWrap.addEventListener('mousemove', me.onLegendHovered, true) w.dom.elWrap.addEventListener('mouseout', me.onLegendHovered, true) } // keyboard navigation support if ( w.config.chart.accessibility.enabled && w.config.chart.accessibility.keyboard.enabled ) { w.dom.elWrap.addEventListener( 'keydown', me.onLegendKeyDown.bind(me), true, ) } } /** * @param {number} offsetX * @param {number} offsetY */ setLegendWrapXY(offsetX, offsetY) { const w = this.w const elLegendWrap = /** @type {HTMLElement} */ (w.dom.elLegendWrap) const legendHeight = elLegendWrap.clientHeight let x = 0 let y = 0 if (w.config.legend.position === 'bottom') { y = w.globals.svgHeight - Math.min(legendHeight, w.globals.svgHeight / 2) - 5 } else if (w.config.legend.position === 'top') { const dim = new Dimensions(this.w, this.ctx) const titleH = dim.dimHelpers.getTitleSubtitleCoords('title').height const subtitleH = dim.dimHelpers.getTitleSubtitleCoords('subtitle').height y = (titleH > 0 ? titleH - 10 : 0) + (subtitleH > 0 ? subtitleH - 10 : 0) } elLegendWrap.style.position = 'absolute' x = x + offsetX + w.config.legend.offsetX y = y + offsetY + w.config.legend.offsetY elLegendWrap.style.left = x + 'px' elLegendWrap.style.top = y + 'px' if (w.config.legend.position === 'right') { elLegendWrap.style.left = 'auto' elLegendWrap.style.right = 25 + w.config.legend.offsetX + 'px' } const fixedHeigthWidth = /** @type {const} */ (['width', 'height']) fixedHeigthWidth.forEach((hw) => { if (elLegendWrap && elLegendWrap.style[hw]) { elLegendWrap.style[hw] = parseInt(String(w.config.legend[hw]), 10) + 'px' } }) } legendAlignHorizontal() { const w = this.w const elLegendWrap = /** @type {HTMLElement} */ (w.dom.elLegendWrap) elLegendWrap.style.right = '0' const dimensions = new Dimensions(this.w, this.ctx) const titleRect = dimensions.dimHelpers.getTitleSubtitleCoords('title') const subtitleRect = dimensions.dimHelpers.getTitleSubtitleCoords('subtitle') const offsetX = 20 let offsetY = 0 if (w.config.legend.position === 'top') { offsetY = titleRect.height + subtitleRect.height + w.config.title.margin + w.config.subtitle.margin - 10 } this.setLegendWrapXY(offsetX, offsetY) } legendAlignVertical() { const w = this.w const lRect = this.legendHelpers.getLegendDimensions() const offsetY = 20 let offsetX = 0 if (w.config.legend.position === 'left') { offsetX = 20 } if (w.config.legend.position === 'right') { offsetX = w.globals.svgWidth - lRect.clww - 10 } this.setLegendWrapXY(offsetX, offsetY) } /** * @param {MouseEvent} e */ onLegendHovered(e) { const w = this.w const target = /** @type {Element} */ (e.target) const hoverOverLegend = target.classList.contains('apexcharts-legend-series') || target.classList.contains('apexcharts-legend-text') || target.classList.contains('apexcharts-legend-marker') if (w.config.chart.type !== 'heatmap' && !this.isBarsDistributed) { if ( !target.classList.contains('apexcharts-inactive-legend') && hoverOverLegend ) { const series = new Series(this.ctx.w) series.toggleSeriesOnHover(e, target) } } else { // for heatmap handling if (hoverOverLegend) { const seriesCnt = parseInt(target.getAttribute('rel') ?? '0', 10) - 1 this.ctx.events.fireEvent('legendHover', [this.ctx, seriesCnt, this.w]) const series = new Series(this.ctx.w) series.highlightRangeInSeries(e, target) } } } /** * @param {KeyboardEvent} e */ onLegendKeyDown(e) { const me = this const w = this.w const target = /** @type {Element} */ (e.target) // Check if event target is a legend item const isLegendItem = target.classList.contains('apexcharts-legend-series') || target.classList.contains('apexcharts-legend-text') || target.classList.contains('apexcharts-legend-marker') if (!isLegendItem) return // Handle Enter or Space key if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() // Prevent page scroll on Space // Capture the rel index before the click (toggleDataSeries re-renders // the legend DOM, which destroys the focused element). const rel = target.getAttribute('rel') // Trigger click handler me.onLegendClick(e) // After re-render, restore focus to the same legend item so the user // can keep toggling without having to re-tab to the legend. if (rel !== null && w.config.legend.onItemClick.toggleDataSeries) { requestAnimationFrame(() => { const restored = w.dom.baseEl.querySelector( `.apexcharts-legend-series[rel="${rel}"]`, ) if (restored) /** @type {HTMLElement} */ (restored).focus() }) } } } /** * @param {Event} e */ onLegendClick(e) { const w = this.w const target = /** @type {Element} */ (e.target) if (w.config.legend.customLegendItems.length) return if ( target.classList.contains('apexcharts-legend-series') || target.classList.contains('apexcharts-legend-text') || target.classList.contains('apexcharts-legend-marker') ) { const seriesCnt = parseInt(target.getAttribute('rel') ?? '0', 10) - 1 const isHidden = target.getAttribute('data:collapsed') === 'true' const legendClick = this.w.config.chart.events.legendClick if (typeof legendClick === 'function') { legendClick(this.ctx, seriesCnt, this.w) } this.ctx.events.fireEvent('legendClick', [this.ctx, seriesCnt, this.w]) const markerClick = this.w.config.legend.markers.onClick if ( typeof markerClick === 'function' && target.classList.contains('apexcharts-legend-marker') ) { markerClick(this.ctx, seriesCnt, this.w) this.ctx.events.fireEvent('legendMarkerClick', [ this.ctx, seriesCnt, this.w, ]) } // for now - just prevent click on heatmap legend - and allow hover only const clickAllowed = w.config.chart.type !== 'treemap' && w.config.chart.type !== 'heatmap' && !this.isBarsDistributed if (clickAllowed && w.config.legend.onItemClick.toggleDataSeries) { this.legendHelpers.toggleDataSeries(seriesCnt, isHidden) } } } } export default Legend