UNPKG

apexcharts

Version:

A JavaScript Chart Library

720 lines (626 loc) 21.2 kB
// @ts-check import Graphics from './Graphics' import Exports from './Exports' import Utils from './../utils/Utils' import { BrowserAPIs } from '../ssr/BrowserAPIs.js' import icoPan from './../assets/ico-pan-hand.svg' import icoZoom from './../assets/ico-zoom-in.svg' import icoReset from './../assets/ico-home.svg' import icoZoomIn from './../assets/ico-plus.svg' import icoZoomOut from './../assets/ico-minus.svg' import icoSelect from './../assets/ico-select.svg' import icoMenu from './../assets/ico-menu.svg' /** * ApexCharts Toolbar Class for creating toolbar in axis based charts. * * @module Toolbar **/ export default class Toolbar { /** * @param {import('../types/internal').ChartStateW} w * @param {import('../types/internal').ChartContext} ctx */ constructor(w, ctx) { this.w = w this.ctx = ctx // needed: getSyncedCharts, fireEvent, user callbacks, Exports this.ev = this.w.config.chart.events this.selectedClass = 'apexcharts-selected' this.localeValues = this.w.globals.locale.toolbar this.minX = w.globals.minX this.maxX = w.globals.maxX /** @type {HTMLElement | null} */ this.elZoom = null /** @type {HTMLElement | null} */ this.elZoomIn = null /** @type {HTMLElement | null} */ this.elZoomOut = null /** @type {HTMLElement | null} */ this.elPan = null /** @type {HTMLElement | null} */ this.elSelection = null /** @type {HTMLElement | null} */ this.elZoomReset = null /** @type {HTMLElement | null} */ this.elMenuIcon = null /** @type {HTMLElement | null} */ this.elMenu = null /** @type {HTMLElement[]} */ this.elMenuItems = [] /** @type {any} */ this.t = null } createToolbar() { const w = this.w const createDiv = () => { return BrowserAPIs.createElementNS('http://www.w3.org/1999/xhtml', 'div') } const elToolbarWrap = createDiv() elToolbarWrap.setAttribute('class', 'apexcharts-toolbar') elToolbarWrap.style.top = w.config.chart.toolbar.offsetY + 'px' elToolbarWrap.style.right = -w.config.chart.toolbar.offsetX + 3 + 'px' w.dom.elWrap.appendChild(elToolbarWrap) this.elZoom = createDiv() this.elZoomIn = createDiv() this.elZoomOut = createDiv() this.elPan = createDiv() this.elSelection = createDiv() this.elZoomReset = createDiv() this.elMenuIcon = createDiv() this.elMenu = createDiv() /** @type {any} */ this.elCustomIcons = [] this.t = w.config.chart.toolbar.tools if (Array.isArray(this.t.customIcons)) { for (let i = 0; i < this.t.customIcons.length; i++) { this.elCustomIcons.push(createDiv()) } } /** @type {any[]} */ const toolbarControls = [] /** * @param {string} type * @param {Element} el * @param {string} ico */ const appendZoomControl = (type, el, ico) => { const tool = type.toLowerCase() if (this.t[tool] && w.config.chart.zoom.enabled) { toolbarControls.push({ el, icon: typeof this.t[tool] === 'string' ? this.t[tool] : ico, title: /** @type {any} */ (this.localeValues)[type], class: `apexcharts-${tool}-icon`, }) } } appendZoomControl('zoomIn', this.elZoomIn, icoZoomIn) appendZoomControl('zoomOut', this.elZoomOut, icoZoomOut) /** * @param {string} z */ const zoomSelectionCtrls = (z) => { if (this.t[z] && w.config.chart[z].enabled) { toolbarControls.push({ el: z === 'zoom' ? this.elZoom : this.elSelection, icon: typeof this.t[z] === 'string' ? this.t[z] : z === 'zoom' ? icoZoom : icoSelect, title: /** @type {any} */ (this.localeValues)[ z === 'zoom' ? 'selectionZoom' : 'selection' ], class: `apexcharts-${z}-icon`, }) } } zoomSelectionCtrls('zoom') zoomSelectionCtrls('selection') if (this.t.pan && w.config.chart.zoom.enabled) { toolbarControls.push({ el: this.elPan, icon: typeof this.t.pan === 'string' ? this.t.pan : icoPan, title: this.localeValues.pan, class: 'apexcharts-pan-icon', }) } appendZoomControl('reset', this.elZoomReset, icoReset) if (this.t.download) { toolbarControls.push({ el: this.elMenuIcon, icon: typeof this.t.download === 'string' ? this.t.download : icoMenu, title: this.localeValues.menu, class: 'apexcharts-menu-icon', }) } for (let i = 0; i < this.elCustomIcons.length; i++) { toolbarControls.push({ el: this.elCustomIcons[i], icon: this.t.customIcons[i].icon, title: this.t.customIcons[i].title, index: this.t.customIcons[i].index, class: 'apexcharts-toolbar-custom-icon ' + this.t.customIcons[i].class, }) } toolbarControls.forEach((t, index) => { if (t.index) { Utils.moveIndexInArray(toolbarControls, index, t.index) } }) for (let i = 0; i < toolbarControls.length; i++) { Graphics.setAttrs(toolbarControls[i].el, { class: toolbarControls[i].class, title: toolbarControls[i].title, tabindex: '0', role: 'button', 'aria-label': toolbarControls[i].title, }) toolbarControls[i].el.innerHTML = toolbarControls[i].icon elToolbarWrap.appendChild(toolbarControls[i].el) } // Toggle-button ARIA pressed state for zoom / selection / pan if (this.elZoom.parentNode) { this.elZoom.setAttribute('aria-pressed', String(!!w.interact.zoomEnabled)) } if (this.elSelection.parentNode) { this.elSelection.setAttribute( 'aria-pressed', String(!!w.interact.selectionEnabled), ) } if (this.elPan.parentNode) { this.elPan.setAttribute('aria-pressed', String(!!w.interact.panEnabled)) } // Menu icon: popup indicator if (this.elMenuIcon.parentNode) { this.elMenuIcon.setAttribute('aria-haspopup', 'true') this.elMenuIcon.setAttribute('aria-expanded', 'false') } this._createHamburgerMenu(elToolbarWrap) if (w.interact.zoomEnabled) { this.elZoom.classList.add(this.selectedClass) } else if (w.interact.panEnabled) { this.elPan.classList.add(this.selectedClass) } else if (w.interact.selectionEnabled) { this.elSelection.classList.add(this.selectedClass) } this.addToolbarEventListeners() } /** * @param {Element} parent */ _createHamburgerMenu(parent) { this.elMenuItems = [] parent.appendChild(/** @type {Node} */ (this.elMenu)) Graphics.setAttrs(this.elMenu, { class: 'apexcharts-menu', role: 'menu', }) const menuItems = [ { name: 'exportSVG', title: this.localeValues.exportToSVG, }, { name: 'exportPNG', title: this.localeValues.exportToPNG, }, { name: 'exportCSV', title: this.localeValues.exportToCSV, }, ] for (let i = 0; i < menuItems.length; i++) { this.elMenuItems.push( BrowserAPIs.createElementNS('http://www.w3.org/1999/xhtml', 'div'), ) this.elMenuItems[i].innerHTML = menuItems[i].title Graphics.setAttrs(this.elMenuItems[i], { class: `apexcharts-menu-item ${menuItems[i].name}`, title: menuItems[i].title, role: 'menuitem', tabindex: '-1', }) ;/** @type {HTMLElement} */ (this.elMenu).appendChild(this.elMenuItems[i]) } } addToolbarEventListeners() { this.elZoomReset?.addEventListener('click', this.handleZoomReset.bind(this)) this.elSelection?.addEventListener( 'click', this.toggleZoomSelection.bind(this, 'selection'), ) this.elZoom?.addEventListener( 'click', this.toggleZoomSelection.bind(this, 'zoom'), ) this.elZoomIn?.addEventListener('click', this.handleZoomIn.bind(this)) this.elZoomOut?.addEventListener('click', this.handleZoomOut.bind(this)) this.elPan?.addEventListener('click', this.togglePanning.bind(this)) this.elMenuIcon?.addEventListener('click', this.toggleMenu.bind(this)) this.elMenuItems.forEach((m) => { if (m.classList.contains('exportSVG')) { m.addEventListener('click', this.handleDownload.bind(this, 'svg')) } else if (m.classList.contains('exportPNG')) { m.addEventListener('click', this.handleDownload.bind(this, 'png')) } else if (m.classList.contains('exportCSV')) { m.addEventListener('click', this.handleDownload.bind(this, 'csv')) } }) for (let i = 0; i < this.t.customIcons.length; i++) { this.elCustomIcons[i].addEventListener( 'click', this.t.customIcons[i].click.bind(this, this.ctx, this.ctx.w), ) } // ── Keyboard accessibility ────────────────────────────────────────────── // Activate any toolbar button (not menu items) with Enter or Space const toolbarButtons = [ this.elZoomReset, this.elSelection, this.elZoom, this.elZoomIn, this.elZoomOut, this.elPan, this.elMenuIcon, ...this.elCustomIcons, ] toolbarButtons.forEach((btn) => { /** * @param {Event} e */ btn.addEventListener('keydown', (/** @type {any} */ e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() // Capture the button's class before click triggers a potential // re-render (zoom-in/out/reset recreate the toolbar DOM, which // removes the focused element and loses focus — same issue as legend). const btnClass = btn.className btn.click() // After re-render, restore focus to the equivalent new button so the // user can keep operating without having to re-tab to the toolbar. requestAnimationFrame(() => { const baseEl = this.w.dom.baseEl if (!baseEl) return // Match on the first apexcharts-specific class (e.g. apexcharts-zoomin-icon) const apexClass = btnClass .split(' ') /** * @param {string} c */ .find((/** @type {any} */ c) => c.startsWith('apexcharts-')) if (!apexClass) return const restored = baseEl.querySelector(`.${apexClass}`) if (restored) /** @type {HTMLElement} */ (restored).focus() }) } }) }) // Menu keyboard navigation: Arrow keys move focus, Escape closes menu this.elMenuIcon?.addEventListener( 'keydown', (/** @type {KeyboardEvent} */ e) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() if (!this.elMenu?.classList.contains('apexcharts-menu-open')) { this.toggleMenu() } // Focus first (ArrowDown) or last (ArrowUp) menu item after menu renders window.setTimeout(() => { const idx = e.key === 'ArrowDown' ? 0 : this.elMenuItems.length - 1 if (this.elMenuItems[idx]) /** @type {HTMLElement} */ (this.elMenuItems[idx]).focus() }, 20) } }, ) this.elMenuItems.forEach((m, idx) => { m.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => { if (e.key === 'ArrowDown') { e.preventDefault() const next = this.elMenuItems[idx + 1] || this.elMenuItems[0] next.focus() } else if (e.key === 'ArrowUp') { e.preventDefault() const prev = this.elMenuItems[idx - 1] || this.elMenuItems[this.elMenuItems.length - 1] prev.focus() } else if (e.key === 'Escape' || e.key === 'Tab') { this._closeMenu() this.elMenuIcon?.focus() if (e.key === 'Tab') { // Allow Tab to continue natural flow — do not prevent default } else { e.preventDefault() } } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() m.click() } }) }) } /** * @param {string} type */ toggleZoomSelection(type) { const charts = this.ctx.getSyncedCharts() /** * @param {Record<string, any>} ch */ charts.forEach((ch) => { ch.ctx.toolbar.toggleOtherControls() const el = type === 'selection' ? ch.ctx.toolbar.elSelection : ch.ctx.toolbar.elZoom const enabledType = type === 'selection' ? 'selectionEnabled' : 'zoomEnabled' ch.w.globals[enabledType] = !ch.w.globals[enabledType] if (!el.classList.contains(ch.ctx.toolbar.selectedClass)) { el.classList.add(ch.ctx.toolbar.selectedClass) } else { el.classList.remove(ch.ctx.toolbar.selectedClass) } el.setAttribute('aria-pressed', String(ch.w.globals[enabledType])) }) } getToolbarIconsReference() { const w = this.w if (!this.elZoom) { this.elZoom = w.dom.baseEl.querySelector('.apexcharts-zoom-icon') } if (!this.elPan) { this.elPan = w.dom.baseEl.querySelector('.apexcharts-pan-icon') } if (!this.elSelection) { this.elSelection = w.dom.baseEl.querySelector( '.apexcharts-selection-icon', ) } } /** * @param {string} type */ enableZoomPanFromToolbar(type) { this.toggleOtherControls() type === 'pan' ? (this.w.interact.panEnabled = true) : (this.w.interact.zoomEnabled = true) const el = type === 'pan' ? this.elPan : this.elZoom const el2 = type === 'pan' ? this.elZoom : this.elPan if (el) { el.classList.add(this.selectedClass) } if (el2) { el2.classList.remove(this.selectedClass) } } togglePanning() { const charts = this.ctx.getSyncedCharts() /** * @param {Record<string, any>} ch */ charts.forEach((ch) => { ch.ctx.toolbar.toggleOtherControls() ch.w.interact.panEnabled = !ch.w.interact.panEnabled if ( !ch.ctx.toolbar.elPan.classList.contains(ch.ctx.toolbar.selectedClass) ) { ch.ctx.toolbar.elPan.classList.add(ch.ctx.toolbar.selectedClass) } else { ch.ctx.toolbar.elPan.classList.remove(ch.ctx.toolbar.selectedClass) } ch.ctx.toolbar.elPan.setAttribute( 'aria-pressed', String(ch.w.interact.panEnabled), ) }) } toggleOtherControls() { const w = this.w w.interact.panEnabled = false w.interact.zoomEnabled = false w.interact.selectionEnabled = false this.getToolbarIconsReference() const toggleEls = [this.elPan, this.elSelection, this.elZoom] toggleEls.forEach((el) => { if (el) { el.classList.remove(this.selectedClass) } }) } handleZoomIn() { const w = this.w if (w.axisFlags.isRangeBar) { this.minX = w.globals.minY this.maxX = w.globals.maxY } const centerX = (this.minX + this.maxX) / 2 const newMinX = (this.minX + centerX) / 2 const newMaxX = (this.maxX + centerX) / 2 const newMinXMaxX = this._getNewMinXMaxX(newMinX, newMaxX) if (!w.interact.disableZoomIn) { this.zoomUpdateOptions(newMinXMaxX.minX, newMinXMaxX.maxX) } } handleZoomOut() { const w = this.w if (w.axisFlags.isRangeBar) { this.minX = w.globals.minY this.maxX = w.globals.maxY } // avoid zooming out beyond 1000 which may result in NaN values being printed on x-axis if ( w.config.xaxis.type === 'datetime' && new Date(this.minX).getUTCFullYear() < 1000 ) { return } const centerX = (this.minX + this.maxX) / 2 const newMinX = this.minX - (centerX - this.minX) const newMaxX = this.maxX - (centerX - this.maxX) const newMinXMaxX = this._getNewMinXMaxX(newMinX, newMaxX) if (!w.interact.disableZoomOut) { this.zoomUpdateOptions(newMinXMaxX.minX, newMinXMaxX.maxX) } } /** * @param {number} newMinX * @param {number} newMaxX */ _getNewMinXMaxX(newMinX, newMaxX) { const shouldFloor = this.w.config.xaxis.convertedCatToNumeric return { minX: shouldFloor ? Math.floor(newMinX) : newMinX, maxX: shouldFloor ? Math.floor(newMaxX) : newMaxX, } } /** * @param {number} newMinX * @param {number} newMaxX */ zoomUpdateOptions(newMinX, newMaxX) { const w = this.w if (newMinX === undefined && newMaxX === undefined) { this.handleZoomReset() return } if (w.config.xaxis.convertedCatToNumeric) { // in category charts, avoid zooming out beyond min and max if (newMinX < 1) { newMinX = 1 newMaxX = w.globals.dataPoints } if (newMaxX - newMinX < 2) { return } } let xaxis = { min: newMinX, max: newMaxX, } const beforeZoomRange = this.getBeforeZoomRange( xaxis, /** @type {any} */ (undefined), ) if (beforeZoomRange) { xaxis = beforeZoomRange.xaxis } /** @type {{ xaxis: any; yaxis?: any }} */ const options = { xaxis, } if (!w.globals.initialConfig) return const yaxis = Utils.clone(w.globals.initialConfig.yaxis) if (!w.config.chart.group) { // if chart in a group, prevent yaxis update here // fix issue #650 options.yaxis = yaxis } this.w.interact.zoomed = true this.ctx.updateHelpers._updateOptions( options, false, this.w.config.chart.animations.dynamicAnimation.enabled, ) this.zoomCallback(xaxis, yaxis) } /** * @param {Record<string, any>} xaxis * @param {Record<string, any>} yaxis */ zoomCallback(xaxis, yaxis) { if (typeof this.ev.zoomed === 'function') { this.ev.zoomed(this.ctx, { xaxis, yaxis }) this.ctx.events.fireEvent('zoomed', { xaxis, yaxis }) } } /** * @param {Record<string, any>} xaxis * @param {Record<string, any>} yaxis */ getBeforeZoomRange(xaxis, yaxis) { let newRange = null if (typeof this.ev.beforeZoom === 'function') { newRange = this.ev.beforeZoom(this, { xaxis, yaxis }) } return newRange } toggleMenu() { window.setTimeout(() => { if (this.elMenu?.classList.contains('apexcharts-menu-open')) { this._closeMenu() } else { this.elMenu?.classList.add('apexcharts-menu-open') this.elMenuIcon?.setAttribute('aria-expanded', 'true') } }, 0) } _closeMenu() { this.elMenu?.classList.remove('apexcharts-menu-open') this.elMenuIcon?.setAttribute('aria-expanded', 'false') } /** * @param {string} type */ handleDownload(type) { const w = this.w const exprt = new Exports(this.w, this.ctx) switch (type) { case 'svg': exprt.exportToSVG() break case 'png': exprt.exportToPng() break case 'csv': exprt.exportToCSV({ series: w.config.series, columnDelimiter: w.config.chart.toolbar.export.csv.columnDelimiter, }) break } } handleZoomReset() { const charts = this.ctx.getSyncedCharts() /** * @param {Record<string, any>} ch */ charts.forEach((ch) => { const w = ch.w // if user is hitting zoom reset button without zooming in first, then we should not fire zoom reset event if (!w.interact.zoomed) return // forget lastXAxis min/max as reset button isn't resetting the x-axis completely if zoomX is called before w.globals.lastXAxis.min = w.globals.initialConfig.xaxis.min w.globals.lastXAxis.max = w.globals.initialConfig.xaxis.max ch.updateHelpers.revertDefaultAxisMinMax() if (typeof w.config.chart.events.beforeResetZoom === 'function') { // here, user get an option to control xaxis and yaxis when resetZoom is called // at this point, whatever is returned from w.config.chart.events.beforeResetZoom // is set as the new xaxis/yaxis min/max const resetZoomRange = w.config.chart.events.beforeResetZoom(ch, w) if (resetZoomRange) { ch.updateHelpers.revertDefaultAxisMinMax(resetZoomRange) } } if (typeof w.config.chart.events.zoomed === 'function') { ch.ctx.toolbar.zoomCallback({ min: w.config.xaxis.min, max: w.config.xaxis.max, }) } // if user has some series collapsed before hitting zoom reset button, // those series should stay collapsed const series = ch.ctx.series.emptyCollapsedSeries( Utils.clone(w.globals.initialSeries), ) ch.updateHelpers._updateSeries( series, w.config.chart.animations.dynamicAnimation.enabled, ) w.interact.zoomed = false }) } destroy() { this.elZoom = null this.elZoomIn = null this.elZoomOut = null this.elPan = null this.elSelection = null this.elZoomReset = null this.elMenuIcon = null } }