import { DeesElement, customElement, property, state, type TemplateResult, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { demoFunc } from './demo.js'; import { chartAreaStyles } from './styles.js'; import { renderChartArea } from './template.js'; import type { IChartApi, ISeriesApi, UTCTimestamp, MouseEventParams } from 'lightweight-charts'; import { DeesServiceLibLoader, type ILightweightChartsBundle } from '../../../services/index.js'; import '../../00group-layout/dees-tile/dees-tile.js'; export type ChartSeriesConfig = { name?: string; data: Array<{ x: any; y: number }>; }[]; declare global { interface HTMLElementTagNameMap { 'dees-chart-area': DeesChartArea; } } @customElement('dees-chart-area') export class DeesChartArea extends DeesElement { public static demo = demoFunc; public static demoGroups = ['Chart']; @state() accessor chart: IChartApi | null = null; @state() accessor seriesStats: Array<{ name: string; latest: number; min: number; max: number; avg: number; color: string }> = []; @state() accessor isFullPage: boolean = false; @property() accessor label: string = 'Untitled Chart'; @property({ type: Array }) accessor series: ChartSeriesConfig = []; get chartSeries(): ChartSeriesConfig { return this.internalChartData.length > 0 ? this.internalChartData : this.series; } @property({ attribute: false }) accessor yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`; @property({ type: Number }) accessor rollingWindow: number = 0; @property({ type: Boolean }) accessor realtimeMode: boolean = false; @property({ type: String }) accessor yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic'; @property({ type: Number }) accessor yAxisMax: number = 100; @property({ type: Number }) accessor autoScrollInterval: number = 1000; private internalChartData: ChartSeriesConfig = []; private autoScrollTimer: number | null = null; private lcBundle: ILightweightChartsBundle | null = null; private seriesApis: Map> = new Map(); private priceLines: Map = new Map(); private tooltipEl: HTMLDivElement | null = null; private readonly CHART_COLORS = { dark: [ 'hsl(217.2 91.2% 59.8%)', 'hsl(173.4 80.4% 40%)', 'hsl(280.3 87.4% 66.7%)', 'hsl(24.6 95% 53.1%)', ], light: [ 'hsl(222.2 47.4% 51.2%)', 'hsl(142.1 76.2% 36.3%)', 'hsl(280.3 47.7% 50.2%)', 'hsl(20.5 90.2% 48.2%)', ] }; constructor() { super(); domtools.elementBasic.setup(); this.registerGarbageFunction(async () => { this.stopAutoScroll(); if (this.chart) { try { this.chart.remove(); this.chart = null; this.seriesApis.clear(); this.priceLines.clear(); } catch (e) { console.error('Error destroying chart:', e); } } }); } public static styles = chartAreaStyles; public render(): TemplateResult { return renderChartArea(this); } // --- Helpers --- private convertDataToLC(data: Array<{ x: any; y: number }>): Array<{ time: UTCTimestamp; value: number }> { return data .map(point => { const ms = typeof point.x === 'number' ? point.x : new Date(point.x).getTime(); return { time: Math.floor(ms / 1000) as UTCTimestamp, value: point.y }; }) .sort((a, b) => (a.time as number) - (b.time as number)); } private hslToRgba(hslColor: string, alpha: number): string { const match = hslColor.match(/hsl\(([^)]+)\)/); if (!match) return hslColor; const parts = match[1].trim().split(/\s+/); if (parts.length < 3) return hslColor; const h = parseFloat(parts[0]) / 360; const s = parseFloat(parts[1]) / 100; const l = parseFloat(parts[2]) / 100; let r: number, g: number, b: number; if (s === 0) { r = g = b = l; } else { const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1/6) return p + (q - p) * 6 * t; if (t < 1/2) return q; if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; return p; }; const q2 = l < 0.5 ? l * (1 + s) : l + s - l * s; const p2 = 2 * l - q2; r = hue2rgb(p2, q2, h + 1/3); g = hue2rgb(p2, q2, h); b = hue2rgb(p2, q2, h - 1/3); } return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha})`; } private getSeriesColors(isDark: boolean) { return isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light; } private applyTheme() { if (!this.chart || !this.lcBundle) return; const isDark = !this.goBright; const colors = this.getSeriesColors(isDark); this.chart.applyOptions({ layout: { background: { type: this.lcBundle.ColorType.Solid as any, color: 'transparent' }, textColor: isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)', attributionLogo: false, }, grid: { vertLines: { color: isDark ? 'hsl(0 0% 8.5%)' : 'hsl(0 0% 95.5%)' }, horzLines: { color: isDark ? 'hsl(0 0% 8.5%)' : 'hsl(0 0% 95.5%)' }, }, } as any); let idx = 0; for (const [, api] of this.seriesApis) { const color = colors[idx % colors.length]; api.applyOptions({ topColor: this.hslToRgba(color, isDark ? 0.4 : 0.5), bottomColor: this.hslToRgba(color, 0), lineColor: color, }); idx++; } } private recreateSeries(chartSeries: ChartSeriesConfig) { if (!this.chart || !this.lcBundle) return; const isDark = !this.goBright; const colors = this.getSeriesColors(isDark); for (const [, api] of this.seriesApis) { this.chart.removeSeries(api); } this.seriesApis.clear(); this.priceLines.clear(); chartSeries.forEach((s, index) => { const color = colors[index % colors.length]; const api = this.chart!.addSeries(this.lcBundle!.AreaSeries, { topColor: this.hslToRgba(color, isDark ? 0.4 : 0.5), bottomColor: this.hslToRgba(color, 0), lineColor: color, lineWidth: 2, lineType: this.lcBundle!.LineType.Curved, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true, crosshairMarkerRadius: 4, }); api.setData(this.convertDataToLC(s.data)); this.updatePriceLines(s.name || `series-${index}`, api, s.data, color); if (this.yAxisScaling !== 'dynamic') { api.applyOptions({ autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: this.yAxisMax }, }), } as any); } this.seriesApis.set(s.name || `series-${index}`, api); }); this.computeStats(chartSeries); } private computeStats(chartSeries: ChartSeriesConfig) { const isDark = !this.goBright; const colors = this.getSeriesColors(isDark); this.seriesStats = chartSeries.map((s, index) => { const values = s.data.map(d => d.y); if (values.length === 0) { return { name: s.name || `series-${index}`, latest: 0, min: 0, max: 0, avg: 0, color: colors[index % colors.length] }; } const latest = values[values.length - 1]; const min = Math.min(...values); const max = Math.max(...values); const avg = Math.round((values.reduce((sum, v) => sum + v, 0) / values.length) * 100) / 100; return { name: s.name || `series-${index}`, latest, min, max, avg, color: colors[index % colors.length] }; }); } private updatePriceLines(name: string, api: ISeriesApi, data: Array<{ x: any; y: number }>, color: string) { // Remove existing price lines for this series const existing = this.priceLines.get(name); if (existing) { for (const line of existing) { api.removePriceLine(line); } } if (data.length === 0) { this.priceLines.set(name, []); return; } const values = data.map(d => d.y); const avg = values.reduce((sum, v) => sum + v, 0) / values.length; const max = Math.max(...values); const lines: any[] = []; lines.push(api.createPriceLine({ price: Math.round(avg * 100) / 100, color: this.hslToRgba(color, 0.5), lineWidth: 1, lineStyle: 2, // Dashed axisLabelVisible: true, title: 'avg', })); // Only show max line if it's more than 10% above average if (avg > 0 && (max - avg) / avg > 0.1) { lines.push(api.createPriceLine({ price: max, color: this.hslToRgba(color, 0.8), lineWidth: 1, lineStyle: 1, // Dotted axisLabelVisible: true, title: 'max', })); } this.priceLines.set(name, lines); } private setupTooltip() { if (!this.chart) return; this.tooltipEl = document.createElement('div'); this.tooltipEl.className = 'lw-tooltip'; this.tooltipEl.style.display = 'none'; this.shadowRoot!.querySelector('.chartContainer')?.appendChild(this.tooltipEl); this.chart.subscribeCrosshairMove((param: MouseEventParams) => { if (!this.tooltipEl) return; if (!param.point || !param.time || param.point.x < 0 || param.point.y < 0) { this.tooltipEl.style.display = 'none'; return; } const isDark = !this.goBright; const bgColor = isDark ? 'hsl(0 0% 9%)' : 'hsl(0 0% 100%)'; const textColor = isDark ? 'hsl(0 0% 95%)' : 'hsl(0 0% 9%)'; const borderColor = isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 89.8%)'; const colors = this.getSeriesColors(isDark); let html = ''; let idx = 0; let hasData = false; for (const [name, api] of this.seriesApis) { const data = param.seriesData.get(api); if (data && 'value' in data && (data as any).value !== undefined) { hasData = true; const color = colors[idx % colors.length]; const formatted = this.yAxisFormatter((data as any).value); html += `
${name}: ${formatted}
`; } idx++; } if (!hasData) { this.tooltipEl.style.display = 'none'; return; } this.tooltipEl.innerHTML = html; Object.assign(this.tooltipEl.style, { display: 'block', background: bgColor, color: textColor, borderColor: borderColor, }); const containerWidth = this.shadowRoot!.querySelector('.chartContainer')!.clientWidth; let left = param.point.x + 16; if (left + 200 > containerWidth) left = param.point.x - 216; this.tooltipEl.style.left = `${left}px`; this.tooltipEl.style.top = `${param.point.y - 16}px`; }); } // --- Lifecycle --- public async firstUpdated() { await this.domtoolsPromise; this.lcBundle = await DeesServiceLibLoader.getInstance().loadLightweightCharts(); await new Promise(resolve => requestAnimationFrame(resolve)); const chartContainer = this.shadowRoot!.querySelector('.chartContainer') as HTMLDivElement; if (!chartContainer) return; const isDark = !this.goBright; try { this.chart = this.lcBundle.createChart(chartContainer, { autoSize: true, layout: { background: { type: this.lcBundle.ColorType.Solid as any, color: 'transparent' }, textColor: isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontSize: 12, attributionLogo: false, }, grid: { vertLines: { color: isDark ? 'hsl(0 0% 8.5%)' : 'hsl(0 0% 95.5%)' }, horzLines: { color: isDark ? 'hsl(0 0% 8.5%)' : 'hsl(0 0% 95.5%)' }, }, rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.1, bottom: 0.1 }, }, timeScale: { borderVisible: false, timeVisible: true, secondsVisible: true, }, crosshair: { mode: this.lcBundle.CrosshairMode.Magnet, }, handleScroll: false, handleScale: false, } as any); 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 }, ], }, ]; this.internalChartData = chartSeries; this.recreateSeries(chartSeries); this.setupTooltip(); } catch (error) { console.error('Failed to initialize chart:', error); } } public async updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('goBright') && this.chart) { this.applyTheme(); } if (changedProperties.has('series') && this.chart && this.series.length > 0) { await this.updateSeries(this.series); } if (changedProperties.has('yAxisFormatter') && this.chart) { // yAxisFormatter is used by the tooltip; LC price scale uses default formatting } if (changedProperties.has('realtimeMode') && this.chart) { if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) { this.startAutoScroll(); } else { this.stopAutoScroll(); } } if (changedProperties.has('autoScrollInterval') && this.chart) { this.stopAutoScroll(); if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) { this.startAutoScroll(); } } if ((changedProperties.has('yAxisScaling') || changedProperties.has('yAxisMax')) && this.chart) { for (const [, api] of this.seriesApis) { if (this.yAxisScaling === 'dynamic') { api.applyOptions({ autoscaleInfoProvider: undefined } as any); } else { api.applyOptions({ autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: this.yAxisMax } }), } as any); } } } } // --- Public API --- public async updateSeries(newSeries: ChartSeriesConfig, animate: boolean = true) { if (!this.chart) return; try { this.internalChartData = newSeries; if (this.rollingWindow > 0 && this.realtimeMode) { const now = Date.now(); const cutoffTime = now - this.rollingWindow; if (newSeries.length !== this.seriesApis.size) { this.recreateSeries(newSeries); } else { const colors = this.getSeriesColors(!this.goBright); newSeries.forEach((s, index) => { const name = s.name || `series-${index}`; const api = this.seriesApis.get(name); if (!api) return; const filtered = s.data.filter(point => { const ms = typeof point.x === 'number' ? point.x : new Date(point.x).getTime(); return ms > cutoffTime; }); api.setData(this.convertDataToLC(filtered)); this.updatePriceLines(name, api, filtered, colors[index % colors.length]); }); this.computeStats(newSeries); } try { this.chart.timeScale().setVisibleRange({ from: Math.floor(cutoffTime / 1000) as UTCTimestamp, to: Math.floor(now / 1000) as UTCTimestamp, }); } catch (e) { /* range may be invalid */ } if (this.yAxisScaling === 'dynamic') { const allValues = newSeries.flatMap(s => s.data.filter(p => { const ms = typeof p.x === 'number' ? p.x : new Date(p.x).getTime(); return ms > cutoffTime; }).map(d => d.y) ); if (allValues.length > 0) { const dynamicMax = Math.ceil(Math.max(...allValues) * 1.1); for (const [, api] of this.seriesApis) { api.applyOptions({ autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: dynamicMax } }), } as any); } } } } else { if (newSeries.length !== this.seriesApis.size) { this.recreateSeries(newSeries); } else { const colors = this.getSeriesColors(!this.goBright); newSeries.forEach((s, index) => { const name = s.name || `series-${index}`; const api = this.seriesApis.get(name); if (!api) return; api.setData(this.convertDataToLC(s.data)); this.updatePriceLines(name, api, s.data, colors[index % colors.length]); }); this.computeStats(newSeries); } } } catch (error) { console.error('Failed to update chart series:', error); } } public async updateTimeWindow() { if (!this.chart || this.rollingWindow <= 0) return; const now = Date.now(); const cutoffTime = now - this.rollingWindow; try { this.chart.timeScale().setVisibleRange({ from: Math.floor(cutoffTime / 1000) as UTCTimestamp, to: Math.floor(now / 1000) as UTCTimestamp, }); } catch (e) { /* range may be invalid */ } } public async appendData(newData: { name?: string; data: Array<{ x: any; y: number }> }[]) { if (!this.chart) return; newData.forEach((s, index) => { const name = s.name || `series-${index}`; const api = this.seriesApis.get(name); if (!api || s.data.length === 0) return; for (const point of s.data) { const lcPoints = this.convertDataToLC([point]); if (lcPoints.length > 0) api.update(lcPoints[0]); } }); } public async updateOptions(options: Record) { if (!this.chart) return; this.chart.applyOptions(options as any); } public async resizeChart() { // With autoSize: true, LC handles resizing automatically. // This method is kept for API compatibility. if (this.chart) { this.chart.timeScale().fitContent(); } } public async forceResize() { await this.resizeChart(); } public toggleFullPage() { this.isFullPage = !this.isFullPage; if (this.isFullPage) { this.style.position = 'fixed'; this.style.inset = '0'; this.style.zIndex = '10000'; this.style.height = '100vh'; this.style.padding = '0'; document.body.style.overflow = 'hidden'; } else { this.style.position = ''; this.style.inset = ''; this.style.zIndex = ''; this.style.height = ''; this.style.padding = ''; document.body.style.overflow = ''; } // Give LC a tick to resize requestAnimationFrame(() => { this.chart?.timeScale().fitContent(); }); } private startAutoScroll() { if (this.autoScrollTimer) return; this.autoScrollTimer = window.setInterval(() => { this.updateTimeWindow(); }, this.autoScrollInterval); } private stopAutoScroll() { if (this.autoScrollTimer) { window.clearInterval(this.autoScrollTimer); this.autoScrollTimer = null; } } }