import { DeesElement, css, cssManager, customElement, html, property, state, type TemplateResult, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { demoFunc } from './dees-chart-area.demo.js'; import ApexCharts from 'apexcharts'; declare global { interface HTMLElementTagNameMap { 'dees-chart-area': DeesChartArea; } } @customElement('dees-chart-area') export class DeesChartArea extends DeesElement { public static demo = demoFunc; // instance @state() public chart: ApexCharts; @property() public label: string = 'Untitled Chart'; @property({ type: Array }) public series: ApexAxisChartSeries = []; // Override getter to return internal chart data get chartSeries(): ApexAxisChartSeries { return this.internalChartData.length > 0 ? this.internalChartData : this.series; } @property({ attribute: false }) public yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`; @property({ type: Number }) public rollingWindow: number = 0; // 0 means no rolling window @property({ type: Boolean }) public realtimeMode: boolean = false; @property({ type: String }) public yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic'; @property({ type: Number }) public yAxisMax: number = 100; // Used when yAxisScaling is 'fixed' or 'percentage' @property({ type: Number }) public autoScrollInterval: number = 1000; // Auto-scroll interval in milliseconds (0 to disable) private resizeObserver: ResizeObserver; private resizeTimeout: number; private internalChartData: ApexAxisChartSeries = []; private autoScrollTimer: number | null = null; private readonly DEBUG_RESIZE = false; // Set to true to enable resize debugging // Chart color schemes private readonly CHART_COLORS = { dark: [ 'hsl(217.2 91.2% 59.8%)', // Blue 'hsl(173.4 80.4% 40%)', // Teal 'hsl(280.3 87.4% 66.7%)', // Purple 'hsl(24.6 95% 53.1%)', // Orange ], light: [ 'hsl(222.2 47.4% 51.2%)', // Blue (shadcn primary) 'hsl(142.1 76.2% 36.3%)', // Green (shadcn success) 'hsl(280.3 47.7% 50.2%)', // Purple (muted) 'hsl(20.5 90.2% 48.2%)', // Orange (shadcn destructive variant) ] }; constructor() { super(); domtools.elementBasic.setup(); this.resizeObserver = new ResizeObserver((entries) => { // Debounce resize calls to prevent excessive updates if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); } this.resizeTimeout = window.setTimeout(() => { // Simply resize if we have a chart, since we're only observing the mainbox if (this.chart) { // Log resize event for debugging if (this.DEBUG_RESIZE && entries.length > 0) { const entry = entries[0]; console.log('DeesChartArea - Resize detected:', { width: entry.contentRect.width, height: entry.contentRect.height }); } this.resizeChart(); } }, 100); // 100ms debounce }); // Note: ResizeObserver is now set up after chart initialization in firstUpdated() // to ensure proper timing and avoid race conditions this.registerGarbageFunction(async () => { if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); } if (this.resizeObserver) { this.resizeObserver.disconnect(); } this.stopAutoScroll(); // Critical: Destroy chart instance to prevent memory leak if (this.chart) { try { this.chart.destroy(); this.chart = null; } catch (error) { console.error('Error destroying chart:', error); } } }); } public async connectedCallback() { super.connectedCallback(); // Trigger resize when element is connected to DOM // This helps with dynamically added charts if (this.chart) { // Wait a frame for layout to settle await new Promise(resolve => requestAnimationFrame(resolve)); await this.resizeChart(); } } public static styles = [ cssManager.defaultStyles, css` :host { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; font-weight: 400; font-size: 14px; } .mainbox { position: relative; width: 100%; height: 400px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 8px; overflow: hidden; } .chartTitle { position: absolute; top: 0; left: 0; width: 100%; text-align: left; padding: 16px 24px; z-index: 10; font-size: 14px; font-weight: 500; letter-spacing: -0.01em; color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 63.9%)')}; } .chartContainer { position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; padding: 44px 16px 16px 0px; overflow: hidden; background: transparent; /* Ensure container doesn't override chart background */ } /* ApexCharts theme overrides */ .apexcharts-canvas { background: transparent !important; } .apexcharts-inner { background: transparent !important; } .apexcharts-graphical { background: transparent !important; } `, ]; public render(): TemplateResult { return html`
${this.label}
`; } public async firstUpdated() { await this.domtoolsPromise; // Wait for next animation frame to ensure layout is complete await new Promise(resolve => requestAnimationFrame(resolve)); // Get actual dimensions of the container const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox'); const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer'); if (!mainbox || !chartContainer) { console.error('Chart containers not found'); return; } // Calculate initial dimensions const styleChartContainer = window.getComputedStyle(chartContainer); const paddingTop = parseInt(styleChartContainer.paddingTop, 10); const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10); const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10); const paddingRight = parseInt(styleChartContainer.paddingRight, 10); const initialWidth = mainbox.clientWidth - paddingLeft - paddingRight; const initialHeight = mainbox.offsetHeight - paddingTop - paddingBottom; // Use provided series data or default demo data const chartSeries = this.series.length > 0 ? this.series : [ { name: 'cpu', data: [ { x: '2025-01-15T03:00:00', y: 25 }, { x: '2025-01-15T07:00:00', y: 30 }, { x: '2025-01-15T11:00:00', y: 20 }, { x: '2025-01-15T15:00:00', y: 35 }, { x: '2025-01-15T19:00:00', y: 25 }, ], }, { name: 'memory', data: [ { x: '2025-01-15T03:00:00', y: 10 }, { x: '2025-01-15T07:00:00', y: 12 }, { x: '2025-01-15T11:00:00', y: 10 }, { x: '2025-01-15T15:00:00', y: 30 }, { x: '2025-01-15T19:00:00', y: 40 }, ], }, ]; // Store internal data this.internalChartData = chartSeries; // Get current theme const isDark = !this.goBright; const theme = isDark ? 'dark' : 'light'; var options: ApexCharts.ApexOptions = { series: chartSeries, chart: { width: initialWidth || 100, // Use actual width or fallback height: initialHeight || 100, // Use actual height or fallback type: 'area', background: 'transparent', // Transparent background to inherit from container toolbar: { show: false, // This line disables the toolbar }, animations: { enabled: !this.realtimeMode, // Disable animations in realtime mode speed: 400, animateGradually: { enabled: false, // Disable gradual animation for cleaner updates delay: 0 }, dynamicAnimation: { enabled: !this.realtimeMode, speed: 350 } }, zoom: { enabled: false, // Disable zoom for cleaner interaction }, selection: { enabled: false, // Disable selection }, }, dataLabels: { enabled: false, }, stroke: { width: 2, curve: 'smooth', }, xaxis: { type: 'datetime', // Time-series data labels: { format: 'HH:mm:ss', // Time formatting with seconds datetimeUTC: false, style: { colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], // Label color fontSize: '12px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontWeight: '400', }, }, axisBorder: { show: false, // Hide x-axis border }, axisTicks: { show: false, // Hide x-axis ticks }, }, yaxis: { min: 0, max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax, labels: { formatter: this.yAxisFormatter, style: { colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], // Label color fontSize: '12px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontWeight: '400', }, }, axisBorder: { show: false, // Hide y-axis border }, axisTicks: { show: false, // Hide y-axis ticks }, }, tooltip: { shared: true, // Enables the tooltip to display across series intersect: false, // Allows hovering anywhere on the chart followCursor: true, // Makes tooltip follow mouse even between points x: { format: 'dd/MM/yy HH:mm', }, custom: ({ series, dataPointIndex, w }: any) => { // Iterate through each series and get its value // Note: We can't access component instance here, so we'll use w.config.theme.mode const currentTheme = w.config.theme.mode; const isDarkMode = currentTheme === 'dark'; const bgColor = isDarkMode ? 'hsl(0 0% 9%)' : 'hsl(0 0% 100%)'; const textColor = isDarkMode ? 'hsl(0 0% 95%)' : 'hsl(0 0% 9%)'; const borderColor = isDarkMode ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 89.8%)'; // Get formatter from chart config const formatter = w.config.yaxis[0]?.labels?.formatter || ((val: number) => val.toString()); let tooltipContent = `
`; series.forEach((s: number[], index: number) => { const label = w.globals.seriesNames[index]; // Get series label const value = s[dataPointIndex]; // Get value at data point const color = w.globals.colors[index]; const formattedValue = formatter(value); tooltipContent += `
${label}: ${formattedValue}
`; }); tooltipContent += `
`; return tooltipContent; }, }, grid: { xaxis: { lines: { show: false, // Hide vertical grid lines for cleaner look }, }, yaxis: { lines: { show: true, }, }, borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)', // Very subtle grid lines strokeDashArray: 0, // Solid line padding: { top: 10, right: 20, bottom: 10, left: 20, }, }, fill: { type: 'gradient', // Gradient fill for the area gradient: { shade: isDark ? 'dark' : 'light', type: 'vertical', shadeIntensity: 0.1, opacityFrom: isDark ? 0.2 : 0.3, opacityTo: 0, stops: [0, 100], }, }, colors: isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light, theme: { mode: theme, }, }; try { this.chart = new ApexCharts(this.shadowRoot.querySelector('.chartContainer'), options); await this.chart.render(); // Give the chart a moment to fully initialize before resizing await new Promise(resolve => setTimeout(resolve, 100)); await this.resizeChart(); // Ensure resize observer is watching the mainbox const mainbox = this.shadowRoot.querySelector('.mainbox'); if (mainbox && this.resizeObserver) { // Disconnect any previous observations this.resizeObserver.disconnect(); // Start observing the mainbox this.resizeObserver.observe(mainbox); if (this.DEBUG_RESIZE) { console.log('DeesChartArea - ResizeObserver attached to mainbox'); } } } catch (error) { console.error('Failed to initialize chart:', error); // Optionally, you could set an error state here // this.chartState = 'error'; // this.errorMessage = 'Failed to initialize chart'; } } public async updated(changedProperties: Map) { super.updated(changedProperties); // Update chart theme when goBright changes if (changedProperties.has('goBright') && this.chart) { await this.updateChartTheme(); } // Update chart if series data changes if (changedProperties.has('series') && this.chart && this.series.length > 0) { await this.updateSeries(this.series); } // Update y-axis formatter if it changes if (changedProperties.has('yAxisFormatter') && this.chart) { await this.chart.updateOptions({ yaxis: { labels: { formatter: this.yAxisFormatter, }, }, }); } // Handle realtime mode changes if (changedProperties.has('realtimeMode') && this.chart) { await this.chart.updateOptions({ chart: { animations: { enabled: !this.realtimeMode, speed: 400, animateGradually: { enabled: false, delay: 0 }, dynamicAnimation: { enabled: !this.realtimeMode, speed: 350 } } } }); // Start/stop auto-scroll based on realtime mode if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) { this.startAutoScroll(); } else { this.stopAutoScroll(); } } // Handle auto-scroll interval changes if (changedProperties.has('autoScrollInterval') && this.chart) { this.stopAutoScroll(); if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) { this.startAutoScroll(); } } // Handle y-axis scaling changes if ((changedProperties.has('yAxisScaling') || changedProperties.has('yAxisMax')) && this.chart) { await this.chart.updateOptions({ yaxis: { min: 0, max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax } }); } } public async updateSeries(newSeries: ApexAxisChartSeries, animate: boolean = true) { if (!this.chart) { return; } try { // Store the new data first this.internalChartData = newSeries; // Handle rolling window if enabled if (this.rollingWindow > 0 && this.realtimeMode) { const now = Date.now(); const cutoffTime = now - this.rollingWindow; // Filter data to only include points within the rolling window const filteredSeries = newSeries.map(series => ({ name: series.name, data: (series.data as any[]).filter(point => { if (typeof point === 'object' && point !== null && 'x' in point) { return new Date(point.x).getTime() > cutoffTime; } return false; }) })); // Only update if we have data if (filteredSeries.some(s => s.data.length > 0)) { // Handle y-axis scaling first if (this.yAxisScaling === 'dynamic') { const allValues = filteredSeries.flatMap(s => (s.data as any[]).map(d => d.y)); if (allValues.length > 0) { const maxValue = Math.max(...allValues); const dynamicMax = Math.ceil(maxValue * 1.1); await this.chart.updateOptions({ yaxis: { min: 0, max: dynamicMax } }, false, false); } } await this.chart.updateSeries(filteredSeries, false); } } else { await this.chart.updateSeries(newSeries, animate); } } catch (error) { console.error('Failed to update chart series:', error); } } // Update just the x-axis for smooth scrolling in realtime mode // Public for advanced usage in demos, but typically handled automatically public async updateTimeWindow() { if (!this.chart || this.rollingWindow <= 0) { return; } const now = Date.now(); const cutoffTime = now - this.rollingWindow; await this.chart.updateOptions({ xaxis: { min: cutoffTime, max: now, labels: { format: 'HH:mm:ss', datetimeUTC: false, style: { colors: [!this.goBright ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], fontSize: '12px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontWeight: '400', }, }, tickAmount: 6, } }, false, false); } public async appendData(newData: { data: any[] }[]) { if (!this.chart) { return; } // Use ApexCharts' appendData method for smoother real-time updates this.chart.appendData(newData); } public async updateOptions(options: ApexCharts.ApexOptions, redrawPaths?: boolean, animate?: boolean) { if (!this.chart) { return; } return this.chart.updateOptions(options, redrawPaths, animate); } public async resizeChart() { if (!this.chart) { return; } if (this.DEBUG_RESIZE) { console.log('DeesChartArea - resizeChart called'); } try { const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox'); const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer'); if (!mainbox || !chartContainer) { return; } // Force layout recalculation void mainbox.offsetHeight; // Get computed style of the element const styleChartContainer = window.getComputedStyle(chartContainer); // Extract padding values const paddingTop = parseInt(styleChartContainer.paddingTop, 10); const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10); const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10); const paddingRight = parseInt(styleChartContainer.paddingRight, 10); // Calculate the actual width and height to use, subtracting padding const actualWidth = mainbox.clientWidth - paddingLeft - paddingRight; const actualHeight = mainbox.offsetHeight - paddingTop - paddingBottom; // Validate dimensions if (actualWidth > 0 && actualHeight > 0) { if (this.DEBUG_RESIZE) { console.log('DeesChartArea - Updating chart dimensions:', { width: actualWidth, height: actualHeight }); } await this.chart.updateOptions({ chart: { width: actualWidth, height: actualHeight, }, }, true, false); // Redraw paths but don't animate } } catch (error) { console.error('Failed to resize chart:', error); } } /** * Manually trigger a chart resize. Useful when automatic detection doesn't work. * This is a convenience method that can be called from outside the component. */ public async forceResize() { await this.resizeChart(); } private startAutoScroll() { if (this.autoScrollTimer) { return; // Already running } this.autoScrollTimer = window.setInterval(() => { this.updateTimeWindow(); }, this.autoScrollInterval); } private stopAutoScroll() { if (this.autoScrollTimer) { window.clearInterval(this.autoScrollTimer); this.autoScrollTimer = null; } } private async updateChartTheme() { if (!this.chart) { return; } const isDark = !this.goBright; const theme = isDark ? 'dark' : 'light'; await this.chart.updateOptions({ theme: { mode: theme, }, colors: isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light, xaxis: { labels: { style: { colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], }, }, }, yaxis: { labels: { style: { colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], }, }, }, grid: { borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)', }, fill: { gradient: { shade: isDark ? 'dark' : 'light', opacityFrom: isDark ? 0.2 : 0.3, }, }, }); } }