From b38bd28360d6a67e9df2539f5ca7cf4a72a80dbd Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 3 Apr 2026 10:11:38 +0000 Subject: [PATCH] feat(chart-area): replace ApexCharts with Lightweight Charts for area chart rendering --- changelog.md | 7 + package.json | 2 +- pnpm-lock.yaml | 18 +- ts_web/00_commitinfo_data.ts | 2 +- .../dees-chart-area/component.ts | 934 ++++++++---------- .../00group-chart/dees-chart-area/styles.ts | 37 +- .../dees-simple-appdash.ts | 1 + ts_web/services/DeesServiceLibLoader.ts | 76 +- ts_web/services/index.ts | 2 +- ts_web/services/versions.ts | 2 +- 10 files changed, 502 insertions(+), 579 deletions(-) diff --git a/changelog.md b/changelog.md index d6d60e7..a43dde5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-03 - 3.51.0 - feat(chart-area) +replace ApexCharts with Lightweight Charts for area chart rendering + +- switch chart dependency and CDN loader from apexcharts to lightweight-charts +- update dees-chart-area styling and tooltip support for the new chart engine +- adjust appdash terminal overlay sizing with automatic height + ## 2026-04-02 - 3.50.2 - fix(chart,dashboardgrid demos) use dees-button text property consistently in demo interactions and update dataset button highlighting reliably diff --git a/package.json b/package.json index e5db121..3aaa755 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@tiptap/extension-underline": "^2.23.0", "@tiptap/starter-kit": "^2.23.0", "@tsclass/tsclass": "^9.5.0", - "apexcharts": "^5.10.4", + "lightweight-charts": "^5.1.0", "highlight.js": "11.11.1", "ibantools": "^4.5.1", "lucide": "^0.577.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c16e38..a77d4d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,15 +62,15 @@ importers: '@tsclass/tsclass': specifier: ^9.5.0 version: 9.5.0 - apexcharts: - specifier: ^5.10.4 - version: 5.10.4 highlight.js: specifier: 11.11.1 version: 11.11.1 ibantools: specifier: ^4.5.1 version: 4.5.1 + lightweight-charts: + specifier: ^5.1.0 + version: 5.1.0 lucide: specifier: ^0.577.0 version: 0.577.0 @@ -2648,6 +2648,9 @@ packages: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} + fancy-canvas@2.1.0: + resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2996,6 +2999,9 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + lightweight-charts@5.1.0: + resolution: {integrity: sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -7810,6 +7816,8 @@ snapshots: fake-indexeddb@6.2.5: {} + fancy-canvas@2.1.0: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -8229,6 +8237,10 @@ snapshots: kind-of@6.0.3: {} + lightweight-charts@5.1.0: + dependencies: + fancy-canvas: 2.1.0 + lines-and-columns@1.2.4: {} linkify-it@5.0.0: diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 563930c..4d0a1cd 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.50.2', + version: '3.51.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-chart/dees-chart-area/component.ts b/ts_web/elements/00group-chart/dees-chart-area/component.ts index 765ce3c..e2785ef 100644 --- a/ts_web/elements/00group-chart/dees-chart-area/component.ts +++ b/ts_web/elements/00group-chart/dees-chart-area/component.ts @@ -11,18 +11,13 @@ import { demoFunc } from './demo.js'; import { chartAreaStyles } from './styles.js'; import { renderChartArea } from './template.js'; -import type ApexCharts from 'apexcharts'; +import type { IChartApi, ISeriesApi, UTCTimestamp, MouseEventParams } from 'lightweight-charts'; +import { DeesServiceLibLoader, type ILightweightChartsBundle } from '../../../services/index.js'; -type ApexAxisChartSeries = { +export type ChartSeriesConfig = { name?: string; - type?: string; - color?: string; - group?: string; - hidden?: boolean; - zIndex?: number; - data: (number | null)[] | { x: any; y: any; [key: string]: any }[] | [number, number | null][] | number[][]; + data: Array<{ x: any; y: number }>; }[]; -import { DeesServiceLibLoader } from '../../../services/index.js'; declare global { interface HTMLElementTagNameMap { @@ -35,18 +30,16 @@ export class DeesChartArea extends DeesElement { public static demo = demoFunc; public static demoGroups = ['Chart']; - // instance @state() - accessor chart!: ApexCharts; + accessor chart: IChartApi | null = null; @property() accessor label: string = 'Untitled Chart'; @property({ type: Array }) - accessor series: ApexAxisChartSeries = []; + accessor series: ChartSeriesConfig = []; - // Override getter to return internal chart data - get chartSeries(): ApexAxisChartSeries { + get chartSeries(): ChartSeriesConfig { return this.internalChartData.length > 0 ? this.internalChartData : this.series; } @@ -54,7 +47,7 @@ export class DeesChartArea extends DeesElement { accessor yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`; @property({ type: Number }) - accessor rollingWindow: number = 0; // 0 means no rolling window + accessor rollingWindow: number = 0; @property({ type: Boolean }) accessor realtimeMode: boolean = false; @@ -63,94 +56,50 @@ export class DeesChartArea extends DeesElement { accessor yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic'; @property({ type: Number }) - accessor yAxisMax: number = 100; // Used when yAxisScaling is 'fixed' or 'percentage' + accessor yAxisMax: number = 100; @property({ type: Number }) - accessor autoScrollInterval: number = 1000; // Auto-scroll interval in milliseconds (0 to disable) + accessor autoScrollInterval: number = 1000; - private resizeObserver!: ResizeObserver; - private resizeTimeout!: number; - private internalChartData: ApexAxisChartSeries = []; + private internalChartData: ChartSeriesConfig = []; private autoScrollTimer: number | null = null; - private readonly DEBUG_RESIZE = false; // Set to true to enable resize debugging - - // Chart color schemes + 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%)', // Blue - 'hsl(173.4 80.4% 40%)', // Teal - 'hsl(280.3 87.4% 66.7%)', // Purple - 'hsl(24.6 95% 53.1%)', // Orange + '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%)', // 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) + '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.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 as any; - } catch (error) { - console.error('Error destroying chart:', error); + this.chart.remove(); + this.chart = null; + this.seriesApis.clear(); + this.priceLines.clear(); + } catch (e) { + console.error('Error destroying chart:', e); } } }); } - - 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 = chartAreaStyles; @@ -158,541 +107,464 @@ export class DeesChartArea extends DeesElement { 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)); + } - public async firstUpdated() { - await this.domtoolsPromise; + 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})`; + } - // Load ApexCharts from CDN - const ApexChartsLib = await DeesServiceLibLoader.getInstance().loadApexCharts(); + private getSeriesColors(isDark: boolean) { + return isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light; + } - // 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 | null = this.shadowRoot!.querySelector('.mainbox'); - const chartContainer: HTMLDivElement | null = this.shadowRoot!.querySelector('.chartContainer'); + private applyTheme() { + if (!this.chart || !this.lcBundle) return; + const isDark = !this.goBright; + const colors = this.getSeriesColors(isDark); - if (!mainbox || !chartContainer) { - console.error('Chart containers not found'); + 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: { visible: false }, + horzLines: { color: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)' }, + }, + } 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.2 : 0.3), + 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.2 : 0.3), + 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); + }); + } + + 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; } - // 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 = `
`; + const values = data.map(d => d.y); + const avg = values.reduce((sum, v) => sum + v, 0) / values.length; + const max = Math.max(...values); - 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} -
`; - }); + const lines: any[] = []; - 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 ApexChartsLib(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'); - } + 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: { visible: false }, + horzLines: { color: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)' }, + }, + 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); - // 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(); + this.applyTheme(); } - - // 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, - }, - }, - }); + // yAxisFormatter is used by the tooltip; LC price scale uses default formatting } - - // 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 + 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 async updateSeries(newSeries: ApexAxisChartSeries, animate: boolean = true) { - if (!this.chart) { - return; - } + // --- Public API --- + + public async updateSeries(newSeries: ChartSeriesConfig, 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; - }) - })); + 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]); + }); + } - // Only update if we have data - if (filteredSeries.some(s => s.data.length > 0)) { - // Build a single options update with series, x-axis window, and y-axis - const isDark = !this.goBright; - const options: ApexCharts.ApexOptions = { - series: filteredSeries, - xaxis: { - min: cutoffTime, - max: now, - labels: { - format: 'HH:mm:ss', - datetimeUTC: false, - style: { - colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], - fontSize: '12px', - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - fontWeight: '400', - }, - }, - tickAmount: 6, - }, - }; + 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 */ } - // Handle y-axis scaling - 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); - options.yaxis = { min: 0, max: dynamicMax }; + 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); } } - - await this.chart.updateOptions(options, false, false); } } else { - await this.chart.updateSeries(newSeries, animate); + 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]); + }); + } } } 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; - } - + 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); + 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: { data: any[] }[]) { - if (!this.chart) { - return; - } - - // Use ApexCharts' appendData method for smoother real-time updates - this.chart.appendData(newData); + 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: ApexCharts.ApexOptions, redrawPaths?: boolean, animate?: boolean) { - if (!this.chart) { - return; - } - - return this.chart.updateOptions(options, redrawPaths, animate); + + public async updateOptions(options: Record) { + if (!this.chart) return; + this.chart.applyOptions(options as any); } public async resizeChart() { - if (!this.chart) { - return; - } - - if (this.DEBUG_RESIZE) { - console.log('DeesChartArea - resizeChart called'); - } - - try { - const mainbox: HTMLDivElement | null = this.shadowRoot!.querySelector('.mainbox'); - const chartContainer: HTMLDivElement | null = 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); + // With autoSize: true, LC handles resizing automatically. + // This method is kept for API compatibility. + if (this.chart) { + this.chart.timeScale().fitContent(); } } - - /** - * 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 - } - + if (this.autoScrollTimer) return; 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, - }, - }, - }); - } } diff --git a/ts_web/elements/00group-chart/dees-chart-area/styles.ts b/ts_web/elements/00group-chart/dees-chart-area/styles.ts index 702d920..cdd51c8 100644 --- a/ts_web/elements/00group-chart/dees-chart-area/styles.ts +++ b/ts_web/elements/00group-chart/dees-chart-area/styles.ts @@ -6,7 +6,6 @@ export const chartAreaStyles = [ :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 { @@ -18,7 +17,6 @@ export const chartAreaStyles = [ border-radius: 8px; overflow: hidden; } - .chartTitle { position: absolute; top: 0; @@ -34,27 +32,22 @@ export const chartAreaStyles = [ } .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 */ + top: 44px; + left: 0; + bottom: 0; + right: 0; } - - /* ApexCharts theme overrides */ - .apexcharts-canvas { - background: transparent !important; - } - - .apexcharts-inner { - background: transparent !important; - } - - .apexcharts-graphical { - background: transparent !important; + .lw-tooltip { + position: absolute; + z-index: 100; + pointer-events: none; + padding: 12px; + border-radius: 6px; + border: 1px solid; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 12px; + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.15); + min-width: 140px; } `, ]; - diff --git a/ts_web/elements/00group-simple/dees-simple-appdash/dees-simple-appdash.ts b/ts_web/elements/00group-simple/dees-simple-appdash/dees-simple-appdash.ts index c31eb53..5f5497a 100644 --- a/ts_web/elements/00group-simple/dees-simple-appdash/dees-simple-appdash.ts +++ b/ts_web/elements/00group-simple/dees-simple-appdash/dees-simple-appdash.ts @@ -710,6 +710,7 @@ export class DeesSimpleAppDash extends DeesElement { terminal.style.top = 'var(--banner-area-height, 0px)'; terminal.style.left = '240px'; terminal.style.right = '0px'; + terminal.style.height = 'auto'; terminal.style.bottom = '24px'; terminal.style.opacity = '0'; terminal.style.transform = 'translateY(8px) scale(0.99)'; diff --git a/ts_web/services/DeesServiceLibLoader.ts b/ts_web/services/DeesServiceLibLoader.ts index dc24954..17bd925 100644 --- a/ts_web/services/DeesServiceLibLoader.ts +++ b/ts_web/services/DeesServiceLibLoader.ts @@ -4,7 +4,15 @@ import { CDN_BASE, CDN_VERSIONS } from './versions.js'; import type { Terminal, ITerminalOptions } from 'xterm'; import type { FitAddon } from 'xterm-addon-fit'; import type { HLJSApi } from 'highlight.js'; -import type ApexChartsType from 'apexcharts'; +import type { + createChart as createChartType, + IChartApi, + ISeriesApi, + UTCTimestamp, + MouseEventParams, + DeepPartial, + TimeChartOptions, +} from 'lightweight-charts'; import type { Editor, EditorOptions } from '@tiptap/core'; import type { StarterKitOptions } from '@tiptap/starter-kit'; import type { UnderlineOptions } from '@tiptap/extension-underline'; @@ -43,6 +51,29 @@ export interface IXtermSearchAddon { findPrevious(term: string, searchOptions?: { regex?: boolean; wholeWord?: boolean; caseSensitive?: boolean; incremental?: boolean }): boolean; } +/** + * Bundle type for TradingView Lightweight Charts + */ +export interface ILightweightChartsBundle { + createChart: typeof createChartType; + AreaSeries: any; + LineSeries: any; + ColorType: { + Solid: string; + VerticalGradient: string; + }; + LineType: { + Simple: number; + WithSteps: number; + Curved: number; + }; + CrosshairMode: { + Normal: number; + Magnet: number; + Hidden: number; + }; +} + /** * Bundle type for Tiptap editor and extensions */ @@ -76,7 +107,7 @@ export class DeesServiceLibLoader { private xtermFitAddonLib: IXtermFitAddonBundle | null = null; private xtermSearchAddonLib: IXtermSearchAddonBundle | null = null; private highlightJsLib: HLJSApi | null = null; - private apexChartsLib: typeof ApexChartsType | null = null; + private lightweightChartsLib: ILightweightChartsBundle | null = null; private tiptapLib: ITiptapBundle | null = null; // Loading promises to prevent duplicate concurrent loads @@ -84,7 +115,7 @@ export class DeesServiceLibLoader { private xtermFitAddonLoadingPromise: Promise | null = null; private xtermSearchAddonLoadingPromise: Promise | null = null; private highlightJsLoadingPromise: Promise | null = null; - private apexChartsLoadingPromise: Promise | null = null; + private lightweightChartsLoadingPromise: Promise | null = null; private tiptapLoadingPromise: Promise | null = null; private constructor() {} @@ -235,27 +266,34 @@ body > div[style*="top: -50000px"][style*="width: 50000px"] { } /** - * Load ApexCharts charting library from CDN - * @returns Promise resolving to ApexCharts constructor + * Load TradingView Lightweight Charts from CDN + * @returns Promise resolving to Lightweight Charts bundle */ - public async loadApexCharts(): Promise { - if (this.apexChartsLib) { - return this.apexChartsLib; + public async loadLightweightCharts(): Promise { + if (this.lightweightChartsLib) { + return this.lightweightChartsLib; } - if (this.apexChartsLoadingPromise) { - return this.apexChartsLoadingPromise; + if (this.lightweightChartsLoadingPromise) { + return this.lightweightChartsLoadingPromise; } - this.apexChartsLoadingPromise = (async () => { - const url = `${CDN_BASE}/apexcharts@${CDN_VERSIONS.apexcharts}/+esm`; + this.lightweightChartsLoadingPromise = (async () => { + const url = `${CDN_BASE}/lightweight-charts@${CDN_VERSIONS.lightweightCharts}/+esm`; const module = await import(/* @vite-ignore */ url); - this.apexChartsLib = module.default; - return this.apexChartsLib!; + this.lightweightChartsLib = { + createChart: module.createChart, + AreaSeries: module.AreaSeries, + LineSeries: module.LineSeries, + ColorType: module.ColorType, + LineType: module.LineType, + CrosshairMode: module.CrosshairMode, + }; + return this.lightweightChartsLib; })(); - return this.apexChartsLoadingPromise; + return this.lightweightChartsLoadingPromise; } /** @@ -316,7 +354,7 @@ body > div[style*="top: -50000px"][style*="width: 50000px"] { this.loadXtermFitAddon(), this.loadXtermSearchAddon(), this.loadHighlightJs(), - this.loadApexCharts(), + this.loadLightweightCharts(), this.loadTiptap(), ]); } @@ -324,7 +362,7 @@ body > div[style*="top: -50000px"][style*="width: 50000px"] { /** * Check if a specific library is already loaded */ - public isLoaded(library: 'xterm' | 'xtermFitAddon' | 'xtermSearchAddon' | 'highlightJs' | 'apexCharts' | 'tiptap'): boolean { + public isLoaded(library: 'xterm' | 'xtermFitAddon' | 'xtermSearchAddon' | 'highlightJs' | 'lightweightCharts' | 'tiptap'): boolean { switch (library) { case 'xterm': return this.xtermLib !== null; @@ -334,8 +372,8 @@ body > div[style*="top: -50000px"][style*="width: 50000px"] { return this.xtermSearchAddonLib !== null; case 'highlightJs': return this.highlightJsLib !== null; - case 'apexCharts': - return this.apexChartsLib !== null; + case 'lightweightCharts': + return this.lightweightChartsLib !== null; case 'tiptap': return this.tiptapLib !== null; default: diff --git a/ts_web/services/index.ts b/ts_web/services/index.ts index fa2e6cd..5098570 100644 --- a/ts_web/services/index.ts +++ b/ts_web/services/index.ts @@ -1,3 +1,3 @@ export { DeesServiceLibLoader } from './DeesServiceLibLoader.js'; -export type { IXtermBundle, IXtermFitAddonBundle, IXtermSearchAddonBundle, IXtermSearchAddon, ITiptapBundle } from './DeesServiceLibLoader.js'; +export type { IXtermBundle, IXtermFitAddonBundle, IXtermSearchAddonBundle, IXtermSearchAddon, ITiptapBundle, ILightweightChartsBundle } from './DeesServiceLibLoader.js'; export { CDN_BASE, CDN_VERSIONS } from './versions.js'; diff --git a/ts_web/services/versions.ts b/ts_web/services/versions.ts index 56e8ba2..bb950bf 100644 --- a/ts_web/services/versions.ts +++ b/ts_web/services/versions.ts @@ -7,7 +7,7 @@ export const CDN_VERSIONS = { xtermAddonFit: '0.8.0', xtermAddonSearch: '0.13.0', highlightJs: '11.11.1', - apexcharts: '5.10.4', + lightweightCharts: '5.1.0', tiptap: '2.27.2', fontawesome: '7.2.0', } as const;