UNPKG

apexcharts

Version:

A JavaScript Chart Library

316 lines (279 loc) 8.23 kB
// @ts-check import ApexCharts from '../apexcharts.js' import { BrowserAPIs } from './BrowserAPIs.js' import { Environment } from '../utils/Environment.js' /** * SSRRenderer - Server-Side Rendering API for ApexCharts * * Provides methods to render charts to SVG strings or hydration-ready HTML * for use in Node.js, Next.js, Nuxt, SvelteKit, and other SSR frameworks. */ export class SSRRenderer { /** * Render chart to SVG string for server-side rendering * * @param {Record<string, any>} options - Chart configuration (same as ApexCharts constructor) * @param {{ width?: number, height?: number, scale?: number }} [ssrOptions] - SSR-specific options * @returns {Promise<string>} SVG string * * @example * const svgString = await SSRRenderer.renderToString({ * series: [{ data: [30, 40, 35] }], * chart: { type: 'bar' } * }, { * width: 500, * height: 300 * }); */ static async renderToString(options, ssrOptions = {}) { // Initialize BrowserAPIs shim if in SSR if (Environment.isSSR()) { BrowserAPIs.init() } const { width = 400, height = 300, scale = 1 } = ssrOptions // Create virtual element context for SSR const virtualEl = this._createVirtualElement(width, height) // Merge config with SSR overrides const ssrConfig = { ...options, chart: { ...options.chart, width, height, // Disable interactive features for SSR toolbar: { show: false }, animations: { enabled: false }, }, } // Create chart instance const chart = new ApexCharts( /** @type {HTMLElement} */ (virtualEl), ssrConfig, ) try { // Render the chart await chart.render() // Extract SVG string // The chart's SVG is in gl.dom.Paper.node const svgString = this._extractSVGString(chart, scale) // Clean up chart.destroy() return svgString } catch (error) { // Ensure cleanup on error chart.destroy() throw new Error( `SSR rendering failed: ${/** @type {any} */ (error).message}`, ) } } /** * Generate hydration-ready HTML with embedded configuration * * @param {Record<string, any>} options - Chart configuration * @param {{ width?: number, height?: number, scale?: number, className?: string }} [ssrOptions] - SSR-specific options * @returns {Promise<string>} HTML string with SVG and hydration data * * @example * const html = await SSRRenderer.renderToHTML({ * series: [{ data: [30, 40, 35] }], * chart: { type: 'bar' } * }, { * width: 500, * height: 300 * }); */ static async renderToHTML(options, ssrOptions = {}) { const { className = '' } = ssrOptions // Render to SVG string const svgString = await this.renderToString(options, ssrOptions) // Encode configuration for client-side hydration const dataConfig = this._encodeConfig(options) // Generate hydration-ready HTML const wrapperClass = `apexcharts-ssr-wrapper${className ? ' ' + className : ''}` return `<div class="${wrapperClass}" data-apexcharts-hydrate data-apexcharts-config="${dataConfig}"> ${svgString} </div>` } /** * Create a virtual DOM element for SSR rendering * @private * @param {number} width * @param {number} height */ static _createVirtualElement(width, height) { if (Environment.isBrowser()) { // In browser, create real element const el = document.createElement('div') el.style.width = `${width}px` el.style.height = `${height}px` return el } // In SSR, create virtual element return { _ssrWidth: width, _ssrHeight: height, _ssrMode: true, nodeType: 1, nodeName: 'DIV', children: /** @type {any[]} */ ([]), style: {}, classList: { add: () => {}, remove: () => {}, contains: () => false, }, /** * @param {any} child */ appendChild(child) { this.children.push(child) }, /** * @param {any} child */ removeChild(child) { const index = this.children.indexOf(child) if (index > -1) this.children.splice(index, 1) }, querySelector() { return null }, querySelectorAll() { return [] }, getElementsByClassName() { return [] }, getAttribute() { return null }, setAttribute() {}, removeAttribute() {}, hasAttribute() { return false }, getBoundingClientRect() { return { width: this._ssrWidth, height: this._ssrHeight, top: 0, left: 0, right: this._ssrWidth, bottom: this._ssrHeight, x: 0, y: 0, } }, get parentNode() { return null }, get isConnected() { return true }, getRootNode() { return this }, } } /** * Extract SVG string from rendered chart * @private * @param {any} chart */ static _extractSVGString(chart, scale = 1) { const w = chart.w if (!w || !w.dom || !w.dom.Paper) { throw new Error('Chart not properly initialized') } const svgNode = w.dom.Paper.node // If we have a real SVG element (browser), serialize it if (Environment.isBrowser() && svgNode instanceof SVGElement) { const serializer = new XMLSerializer() let svgString = serializer.serializeToString(svgNode) // Apply scale if needed if (scale !== 1) { svgString = this._applyScale(svgString, scale) } return svgString } // In SSR, use the toString() method from SSRElement if (svgNode && typeof svgNode.toString === 'function') { let svgString = svgNode.toString() // Apply scale if needed if (scale !== 1) { svgString = this._applyScale(svgString, scale) } return svgString } throw new Error('Unable to extract SVG string from chart') } /** * Apply scale transformation to SVG string * @private * @param {string} svgString * @param {number} scale */ static _applyScale(svgString, scale) { // Parse width and height from SVG const widthMatch = svgString.match(/width="([^"]+)"/) const heightMatch = svgString.match(/height="([^"]+)"/) if (widthMatch && heightMatch) { const width = parseFloat(widthMatch[1]) const height = parseFloat(heightMatch[1]) const scaledWidth = width * scale const scaledHeight = height * scale // Replace width and height attributes svgString = svgString .replace(/width="[^"]+"/, `width="${scaledWidth}"`) .replace(/height="[^"]+"/, `height="${scaledHeight}"`) } return svgString } /** * Encode configuration for client-side hydration * @private * @param {Record<string, any>} config */ static _encodeConfig(config) { try { const json = JSON.stringify(config) // Use Buffer in Node.js, btoa in browser if (typeof Buffer !== 'undefined') { return Buffer.from(json).toString('base64') } else if (typeof btoa !== 'undefined') { return btoa(json) } // Fallback: URL-safe JSON encoding return encodeURIComponent(json) } catch (error) { throw new Error( `Failed to encode config: ${/** @type {any} */ (error).message}`, ) } } /** * Decode configuration from hydration data * @private * @param {string} encodedConfig */ static _decodeConfig(encodedConfig) { try { let json // Try Buffer first (Node.js) if (typeof Buffer !== 'undefined') { json = Buffer.from(encodedConfig, 'base64').toString('utf-8') } else if (typeof atob !== 'undefined') { json = atob(encodedConfig) } else { // Fallback: URL-safe JSON decoding json = decodeURIComponent(encodedConfig) } return JSON.parse(json) } catch (error) { throw new Error( `Failed to decode config: ${/** @type {any} */ (error).message}`, ) } } }