apexcharts
Version:
A JavaScript Chart Library
249 lines (215 loc) • 7.06 kB
JavaScript
// @ts-check
import ApexCharts from '../apexcharts.js'
import { Environment } from '../utils/Environment.js'
/**
* Hydration - Client-side hydration for server-rendered charts
*
* Provides methods to reactivate server-rendered charts with full interactivity,
* animations, and event handling without visual flash.
*/
export class Hydration {
/**
* Hydrate a single server-rendered chart
*
* @param {HTMLElement} el - Container element with data-apexcharts-hydrate attribute
* @param {object} clientOptions - Optional config overrides for client-side (e.g., enable animations)
* @returns {ApexCharts} Hydrated chart instance
*
* @example
* // Hydrate with default settings
* const chart = Hydration.hydrate(document.getElementById('my-chart'));
*
* @example
* // Hydrate with custom options
* const chart = Hydration.hydrate(element, {
* chart: {
* animations: { enabled: true, speed: 800 }
* }
* });
*/
static hydrate(el, clientOptions = {}) {
// Only works in browser
if (!Environment.isBrowser()) {
throw new Error('Hydration can only be performed in browser environment')
}
if (!el) {
throw new Error('Element is required for hydration')
}
// Verify element has hydration data
if (!el.hasAttribute('data-apexcharts-hydrate')) {
throw new Error('Element does not have data-apexcharts-hydrate attribute')
}
// Extract configuration from data attribute
const configAttr = el.getAttribute('data-apexcharts-config')
if (!configAttr) {
throw new Error('Element is missing data-apexcharts-config attribute')
}
// Decode the configuration
const ssrConfig = this._decodeConfig(configAttr)
// Merge SSR config with client overrides
const config = this._mergeConfigs(ssrConfig, clientOptions)
// Store reference to SSR content for smooth transition
const ssrContent = el.innerHTML
// Create chart instance
const chart = new ApexCharts(el, config)
// Before clearing SSR content, measure to avoid flash
const rect = el.getBoundingClientRect()
// Set explicit dimensions to prevent layout shift
el.style.width = `${rect.width}px`
el.style.height = `${rect.height}px`
// Clear SSR content
el.innerHTML = ''
// Remove hydration attributes
el.removeAttribute('data-apexcharts-hydrate')
el.removeAttribute('data-apexcharts-config')
// Render chart (this will create new interactive SVG)
chart
.render()
.then(() => {
// Mark as hydrated
el.setAttribute('data-apexcharts-hydrated', 'true')
// Remove explicit dimensions (let chart be responsive)
el.style.width = ''
el.style.height = ''
// Dispatch custom event for tracking
const event = new CustomEvent('apexcharts:hydrated', {
detail: { chart, ssrContent },
})
el.dispatchEvent(event)
})
.catch((error) => {
console.error('ApexCharts hydration failed:', error)
// Restore SSR content on failure
el.innerHTML = ssrContent
el.setAttribute('data-apexcharts-hydrate', '')
el.setAttribute('data-apexcharts-config', configAttr)
throw error
})
return chart
}
/**
* Auto-hydrate all server-rendered charts on the page
*
* @param {string} selector - CSS selector for containers (default: '[data-apexcharts-hydrate]')
* @param {object} clientOptions - Optional config overrides applied to all charts
* @returns {ApexCharts[]} Array of hydrated chart instances
*
* @example
* // Hydrate all charts on page load
* document.addEventListener('DOMContentLoaded', () => {
* ApexCharts.hydrateAll();
* });
*
* @example
* // Hydrate with animations enabled
* ApexCharts.hydrateAll('[data-apexcharts-hydrate]', {
* chart: { animations: { enabled: true } }
* });
*/
static hydrateAll(
selector = '[data-apexcharts-hydrate]',
clientOptions = {},
) {
// Only works in browser
if (!Environment.isBrowser()) {
throw new Error('Hydration can only be performed in browser environment')
}
const elements = document.querySelectorAll(selector)
if (elements.length === 0) {
console.warn(`No elements found matching selector: ${selector}`)
return []
}
/** @type {any[]} */
const charts = []
elements.forEach((el) => {
try {
const chart = this.hydrate(
/** @type {HTMLElement} */ (el),
clientOptions,
)
charts.push(chart)
} catch (error) {
console.error('Failed to hydrate element:', el, error)
}
})
return charts
}
/**
* Check if an element has been hydrated
*
* @param {HTMLElement} el - Element to check
* @returns {boolean} True if element has been hydrated
*/
static isHydrated(el) {
if (!el) return false
return el.hasAttribute('data-apexcharts-hydrated')
}
/**
* Decode configuration from base64-encoded data attribute
* @private
* @param {string} encodedConfig
* @returns {any}
*/
static _decodeConfig(encodedConfig) {
try {
let json
// Try atob (browser standard)
if (typeof atob !== 'undefined') {
json = atob(encodedConfig)
} else if (typeof Buffer !== 'undefined') {
// Fallback to Buffer (if polyfilled in browser)
json = Buffer.from(encodedConfig, 'base64').toString('utf-8')
} else {
// Fallback: URL-safe JSON decoding
json = decodeURIComponent(encodedConfig)
}
return JSON.parse(json)
} catch (error) {
throw new Error(
`Failed to decode chart config: ${/** @type {any} */ (error).message}`,
)
}
}
/**
* Merge SSR configuration with client-side overrides
* @private
* @param {Record<string, any>} ssrConfig
* @param {Record<string, any>} clientOptions
* @returns {any}
*/
static _mergeConfigs(ssrConfig, clientOptions) {
// Deep merge, with clientOptions taking precedence
const merged = {
...ssrConfig,
...clientOptions,
}
// Special handling for chart object (deep merge)
if (ssrConfig.chart || clientOptions.chart) {
merged.chart = {
...ssrConfig.chart,
...clientOptions.chart,
}
// Re-enable animations by default for hydration (unless explicitly disabled)
if (
merged.chart.animations === undefined ||
merged.chart.animations.enabled === false
) {
merged.chart.animations = {
...(merged.chart.animations || {}),
enabled: true,
}
}
// Re-enable toolbar if it was disabled for SSR (unless explicitly set)
if (
clientOptions.chart?.toolbar === undefined &&
ssrConfig.chart?.toolbar?.show === false
) {
merged.chart.toolbar = {
...(merged.chart.toolbar || {}),
show: true,
}
}
}
return merged
}
}