apexcharts
Version:
A JavaScript Chart Library
601 lines (529 loc) • 17.8 kB
JavaScript
// @ts-check
import apexchartsLegendCSS from '../assets/apexcharts-legend.css'
import AxesUtils from '../modules/axes/AxesUtils'
import Data from '../modules/Data'
import Series from '../modules/Series'
import Utils from '../utils/Utils'
import { Environment } from '../utils/Environment.js'
class Exports {
/**
* @param {import('../types/internal').ChartStateW} w
* @param {import('../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx // needed: theme, timeScale (for AxesUtils), passes ctx to Data/Series
}
/**
* @param {string} svgString
*/
svgStringToNode(svgString) {
const parser = new DOMParser()
const svgDoc = parser.parseFromString(svgString, 'image/svg+xml')
return svgDoc.documentElement
}
/**
* @param {any} svg
* @param {number} scale
*/
scaleSvgNode(svg, scale) {
// get current both width and height of the svg
const svgWidth = parseFloat(svg.getAttributeNS(null, 'width'))
const svgHeight = parseFloat(svg.getAttributeNS(null, 'height'))
// set new width and height based on the scale
svg.setAttributeNS(null, 'width', svgWidth * scale)
svg.setAttributeNS(null, 'height', svgHeight * scale)
svg.setAttributeNS(null, 'viewBox', '0 0 ' + svgWidth + ' ' + svgHeight)
}
/**
* @param {number} [_scale]
*/
getSvgString(_scale) {
return new Promise((resolve) => {
const w = this.w
let scale =
_scale ||
w.config.chart.toolbar.export.scale ||
w.config.chart.toolbar.export.width / w.globals.svgWidth
if (!scale) {
scale = 1 // if no scale is specified, don't scale...
}
const width = w.globals.svgWidth * scale
const height = w.globals.svgHeight * scale
const clonedNode = /** @type {HTMLElement} */ (
w.dom.elWrap.cloneNode(true)
)
clonedNode.style.width = width + 'px'
clonedNode.style.height = height + 'px'
const serializedNode = new XMLSerializer().serializeToString(clonedNode)
// Check if legend is shown and should be included in export
const shouldIncludeLegendStyles =
w.config.legend.show &&
w.dom.elLegendWrap &&
w.dom.elLegendWrap.children.length > 0
// Base styles for export
let exportStyles = `
.apexcharts-tooltip, .apexcharts-toolbar, .apexcharts-xaxistooltip, .apexcharts-yaxistooltip, .apexcharts-xcrosshairs, .apexcharts-ycrosshairs, .apexcharts-zoom-rect, .apexcharts-selection-rect {
display: none;
}
`
// Add legend styles if legend is shown
if (shouldIncludeLegendStyles) {
exportStyles += apexchartsLegendCSS
}
let svgString = `
<svg xmlns="http://www.w3.org/2000/svg"
version="1.1"
xmlns:xlink="http://www.w3.org/1999/xlink"
class="apexcharts-svg"
xmlns:data="ApexChartsNS"
transform="translate(0, 0)"
width="${w.globals.svgWidth}px" height="${w.globals.svgHeight}px">
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml" style="width:${width}px; height:${height}px;">
<style type="text/css">
${exportStyles}
</style>
${serializedNode}
</div>
</foreignObject>
</svg>
`
const svgNode = this.svgStringToNode(svgString)
if (scale !== 1) {
// scale the image
this.scaleSvgNode(svgNode, scale)
}
this.convertImagesToBase64(svgNode).then(() => {
svgString = new XMLSerializer().serializeToString(svgNode)
resolve(svgString.replace(/ /g, ' '))
})
})
}
/**
* @param {any} svgNode
*/
convertImagesToBase64(svgNode) {
const images = svgNode.getElementsByTagName('image')
const promises = Array.from(images).map((img) => {
const href = img.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
if (href && !href.startsWith('data:')) {
return this.getBase64FromUrl(href)
.then((base64) => {
img.setAttributeNS('http://www.w3.org/1999/xlink', 'href', base64)
})
.catch((error) => {
console.error('Error converting image to base64:', error)
})
}
return Promise.resolve()
})
return Promise.all(promises)
}
/**
* @param {string} url
*/
getBase64FromUrl(url) {
if (Environment.isSSR()) return Promise.resolve(url)
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
if (ctx) ctx.drawImage(img, 0, 0)
resolve(canvas.toDataURL())
}
img.onerror = reject
img.src = url
})
}
svgUrl() {
return new Promise((resolve) => {
this.getSvgString().then((svgData) => {
const svgBlob = new Blob([svgData], {
type: 'image/svg+xml;charset=utf-8',
})
resolve(URL.createObjectURL(svgBlob))
})
})
}
/**
* @param {Record<string, any> | undefined} options
*/
dataURI(options) {
if (Environment.isSSR()) return Promise.resolve({ imgURI: '' })
return new Promise((resolve) => {
const w = this.w
const scale = options
? options.scale || options.width / w.globals.svgWidth
: 1
const canvas = document.createElement('canvas')
canvas.width = w.globals.svgWidth * scale
canvas.height = parseInt(w.dom.elWrap.style.height, 10) * scale // because of resizeNonAxisCharts
const canvasBg =
w.config.chart.background === 'transparent' ||
!w.config.chart.background
? '#fff'
: w.config.chart.background
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.fillStyle = canvasBg
ctx.fillRect(0, 0, canvas.width * scale, canvas.height * scale)
this.getSvgString(scale).then((svgData) => {
const svgUrl = 'data:image/svg+xml,' + encodeURIComponent(svgData)
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
ctx.drawImage(img, 0, 0)
/** @type {any} */ const edgeCanvas = canvas
if (edgeCanvas.msToBlob) {
// Legacy Microsoft Edge can't navigate to data urls, so return the blob instead
const blob = edgeCanvas.msToBlob()
resolve({ blob })
} else {
const imgURI = canvas.toDataURL('image/png')
resolve({ imgURI })
}
}
img.src = svgUrl
})
})
}
exportToSVG() {
this.svgUrl().then((url) => {
this.triggerDownload(
url,
this.w.config.chart.toolbar.export.svg.filename,
'.svg',
)
})
}
exportToPng() {
const scale = this.w.config.chart.toolbar.export.scale
const width = this.w.config.chart.toolbar.export.width
const option = scale
? { scale: scale }
: width
? { width: width }
: undefined
this.dataURI(option).then(({ imgURI, blob }) => {
if (blob) {
// @ts-ignore — msSaveOrOpenBlob is an IE11-only API
navigator.msSaveOrOpenBlob(blob, this.w.globals.chartID + '.png')
} else {
this.triggerDownload(
imgURI,
this.w.config.chart.toolbar.export.png.filename,
'.png',
)
}
})
}
/** @param {{ series?: any, fileName?: any, columnDelimiter?: string, lineDelimiter?: string }} opts */
exportToCSV({
series,
fileName,
columnDelimiter = ',',
lineDelimiter = '\n',
}) {
const w = this.w
if (!series) series = w.config.series
/** @type {any[]} */
let columns = []
const rows = []
let result = ''
const universalBOM = '\uFEFF'
/**
* @param {Record<string, any>} s
* @param {number} i
*/
const gSeries = w.seriesData.series.map((s, i) => {
return w.globals.collapsedSeriesIndices.indexOf(i) === -1 ? s : []
})
/**
* @param {any} cat
*/
const getFormattedCategory = (cat) => {
if (
typeof w.config.chart.toolbar.export.csv.categoryFormatter ===
'function'
) {
return w.config.chart.toolbar.export.csv.categoryFormatter(cat)
}
if (w.config.xaxis.type === 'datetime' && String(cat).length >= 10) {
return new Date(cat).toDateString()
}
return Utils.isNumber(cat) ? cat : cat.split(columnDelimiter).join('')
}
/**
* @param {any} value
*/
const getFormattedValue = (value) => {
return typeof w.config.chart.toolbar.export.csv.valueFormatter ===
'function'
? w.config.chart.toolbar.export.csv.valueFormatter(value)
: value
}
const seriesMaxDataLength = Math.max(
/**
* @param {Record<string, any>} s
*/
...series.map((/** @type {any} */ s) => {
return s.data ? s.data.length : 0
}),
)
const dataFormat = new Data(this.w)
const axesUtils = new AxesUtils(this.w, {
theme: this.ctx.theme,
timeScale: this.ctx.timeScale,
})
/**
* @param {number} i
*/
const getCat = (i) => {
let cat = ''
// pie / donut/ radial
if (!w.globals.axisCharts) {
cat = w.config.labels[i]
} else {
// xy charts
// non datetime
if (
w.config.xaxis.type === 'category' ||
w.config.xaxis.convertedCatToNumeric
) {
if (w.globals.isBarHorizontal) {
const lbFormatter = w.formatters.yLabelFormatters[0]
const sr = new Series(this.ctx.w)
const activeSeries = sr.getActiveConfigSeriesIndex()
cat = lbFormatter(w.labelData.labels[i], {
seriesIndex: activeSeries,
dataPointIndex: i,
w,
})
} else {
cat = axesUtils.getLabel(
w.labelData.labels,
w.labelData.timescaleLabels,
0,
i,
).text
}
}
// datetime, but labels specified in categories or labels
if (w.config.xaxis.type === 'datetime') {
if (w.config.xaxis.categories.length) {
cat = w.config.xaxis.categories[i]
} else if (w.config.labels.length) {
cat = w.config.labels[i]
}
}
}
// let the caller know the current category is null. this can happen for example
// when dealing with line charts having inconsistent time series data
if (cat === null) return 'nullvalue'
if (Array.isArray(cat)) {
cat = cat.join(' ')
}
return Utils.isNumber(cat) ? cat : cat.split(columnDelimiter).join('')
}
// Fix https://github.com/apexcharts/apexcharts.js/issues/3365
const getEmptyDataForCsvColumn = () => {
return [...Array(seriesMaxDataLength)].map(() => '')
}
/**
* @param {Record<string, any>} s
* @param {number} sI
*/
const handleAxisRowsColumns = (s, sI) => {
if (columns.length && sI === 0) {
// It's the first series. Go ahead and create the first row with header information.
rows.push(columns.join(columnDelimiter))
}
if (s.data) {
// Use the data we have, or generate a properly sized empty array with empty data if some data is missing.
s.data = (s.data.length && s.data) || getEmptyDataForCsvColumn()
for (let i = 0; i < s.data.length; i++) {
// Reset the columns array so that we can start building columns for this row.
columns = []
let cat = getCat(i)
// current category is null, let's move on to the next one
if (cat === 'nullvalue') continue
if (!cat) {
if (dataFormat.isFormatXY()) {
cat = series[sI].data[i].x
} else if (dataFormat.isFormat2DArray()) {
cat = series[sI].data[i] ? series[sI].data[i][0] : ''
}
}
if (sI === 0) {
// It's the first series. Also handle the category.
columns.push(getFormattedCategory(cat))
for (let ci = 0; ci < w.seriesData.series.length; ci++) {
const value = dataFormat.isFormatXY()
? series[ci].data[i]?.y
: gSeries[ci][i]
columns.push(getFormattedValue(value))
}
}
if (
w.config.chart.type === 'candlestick' ||
(s.type && s.type === 'candlestick')
) {
columns.pop()
columns.push(w.candleData.seriesCandleO[sI][i])
columns.push(w.candleData.seriesCandleH[sI][i])
columns.push(w.candleData.seriesCandleL[sI][i])
columns.push(w.candleData.seriesCandleC[sI][i])
}
if (
w.config.chart.type === 'boxPlot' ||
(s.type && s.type === 'boxPlot')
) {
columns.pop()
columns.push(w.candleData.seriesCandleO[sI][i])
columns.push(w.candleData.seriesCandleH[sI][i])
columns.push(w.candleData.seriesCandleM[sI][i])
columns.push(w.candleData.seriesCandleL[sI][i])
columns.push(w.candleData.seriesCandleC[sI][i])
}
if (w.config.chart.type === 'rangeBar') {
columns.pop()
columns.push(w.rangeData.seriesRangeStart[sI][i])
columns.push(w.rangeData.seriesRangeEnd[sI][i])
}
if (columns.length) {
rows.push(columns.join(columnDelimiter))
}
}
}
}
const handleUnequalXValues = () => {
const categories = new Set()
const data = {}
/**
* @param {Record<string, any>} s
* @param {number} sI
*/
series.forEach((/** @type {any} */ s, /** @type {any} */ sI) => {
/**
* @param {Record<string, any>} dataItem
*/
s?.data.forEach((/** @type {any} */ dataItem) => {
let cat, value
if (dataFormat.isFormatXY()) {
cat = dataItem.x
value = dataItem.y
} else if (dataFormat.isFormat2DArray()) {
cat = dataItem[0]
value = dataItem[1]
} else {
return
}
if (!(/** @type {Record<string,any>} */ (data)[cat])) {
;/** @type {Record<string,any>} */ (data)[cat] = Array(
series.length,
).fill('')
}
;/** @type {Record<string,any>} */ (data)[cat][sI] =
getFormattedValue(value)
categories.add(cat)
})
})
if (columns.length) {
rows.push(columns.join(columnDelimiter))
}
Array.from(categories)
.sort()
.forEach((cat) => {
rows.push([
getFormattedCategory(cat),
/** @type {Record<string,any>} */ (data)[cat].join(columnDelimiter),
])
})
}
columns.push(w.config.chart.toolbar.export.csv.headerCategory)
if (w.config.chart.type === 'boxPlot') {
columns.push('minimum')
columns.push('q1')
columns.push('median')
columns.push('q3')
columns.push('maximum')
} else if (w.config.chart.type === 'candlestick') {
columns.push('open')
columns.push('high')
columns.push('low')
columns.push('close')
} else if (w.config.chart.type === 'rangeBar') {
columns.push('minimum')
columns.push('maximum')
} else {
/**
* @param {Record<string, any>} s
* @param {number} sI
*/
series.map((/** @type {any} */ s, /** @type {any} */ sI) => {
const sname = (s.name ? s.name : `series-${sI}`) + ''
if (w.globals.axisCharts) {
columns.push(
sname.split(columnDelimiter).join('')
? sname.split(columnDelimiter).join('')
: `series-${sI}`,
)
}
})
}
if (!w.globals.axisCharts) {
columns.push(w.config.chart.toolbar.export.csv.headerValue)
rows.push(columns.join(columnDelimiter))
}
if (
!w.globals.allSeriesHasEqualX &&
w.globals.axisCharts &&
!w.config.xaxis.categories.length &&
!w.config.labels.length
) {
handleUnequalXValues()
} else {
/**
* @param {Record<string, any>} s
* @param {number} sI
*/
series.map((/** @type {any} */ s, /** @type {any} */ sI) => {
if (w.globals.axisCharts) {
handleAxisRowsColumns(s, sI)
} else {
columns = []
columns.push(getFormattedCategory(w.labelData.labels[sI]))
columns.push(getFormattedValue(gSeries[sI]))
rows.push(columns.join(columnDelimiter))
}
})
}
result += rows.join(lineDelimiter)
this.triggerDownload(
'data:text/csv; charset=utf-8,' +
encodeURIComponent(universalBOM + result),
fileName ? fileName : w.config.chart.toolbar.export.csv.filename,
'.csv',
)
}
/**
* @param {string} href
* @param {string} filename
* @param {string} ext
*/
triggerDownload(href, filename, ext) {
if (Environment.isSSR()) return
const downloadLink = document.createElement('a')
downloadLink.href = href
downloadLink.download = (filename ? filename : this.w.globals.chartID) + ext
document.body.appendChild(downloadLink)
downloadLink.click()
document.body.removeChild(downloadLink)
}
}
export default Exports