diff --git a/package.json b/package.json index a19a4d4..448b981 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@tiptap/extension-underline": "^2.23.0", "@tiptap/starter-kit": "^2.23.0", "@tsclass/tsclass": "^9.5.0", + "echarts": "^5.6.0", "lightweight-charts": "^5.1.0", "highlight.js": "11.11.1", "ibantools": "^4.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a77d4d4..f04b133 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@tsclass/tsclass': specifier: ^9.5.0 version: 9.5.0 + echarts: + specifier: ^5.6.0 + version: 5.6.0 highlight.js: specifier: 11.11.1 version: 11.11.1 @@ -2534,6 +2537,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3936,6 +3942,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4149,6 +4158,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -7671,6 +7683,11 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + emoji-regex@8.0.0: {} end-of-stream@1.4.5: @@ -9510,6 +9527,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.3.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -9699,4 +9718,8 @@ snapshots: zod@3.25.76: {} + zrender@5.6.1: + dependencies: + tslib: 2.3.0 + zwitch@2.0.4: {} diff --git a/ts_web/elements/00group-chart/dees-chart-bar/component.ts b/ts_web/elements/00group-chart/dees-chart-bar/component.ts new file mode 100644 index 0000000..ad6adb9 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-bar/component.ts @@ -0,0 +1,142 @@ +import { + customElement, + property, + type TemplateResult, +} from '@design.estate/dees-element'; + +import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js'; +import { demoFunc } from './demo.js'; +import { barStyles } from './styles.js'; +import { renderChartBar } from './template.js'; +import { getEchartsSeriesColors } from '../dees-chart-echarts-theme.js'; + +export interface IBarSeriesItem { + name: string; + data: number[]; + color?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'dees-chart-bar': DeesChartBar; + } +} + +@customElement('dees-chart-bar') +export class DeesChartBar extends DeesChartEchartsBase { + public static demo = demoFunc; + public static demoGroups = ['Chart']; + + @property({ type: Array }) + accessor categories: string[] = []; + + @property({ type: Array }) + accessor series: IBarSeriesItem[] = []; + + @property({ type: Boolean }) + accessor horizontal: boolean = false; + + @property({ type: Boolean }) + accessor stacked: boolean = false; + + @property({ type: Boolean }) + accessor showLegend: boolean = true; + + @property({ attribute: false }) + accessor valueFormatter: (value: number) => string = (val) => `${val}`; + + public static styles = barStyles; + + public render(): TemplateResult { + return renderChartBar(this); + } + + public async updated(changedProperties: Map) { + super.updated(changedProperties); + if ( + this.chartInstance && + (changedProperties.has('categories') || + changedProperties.has('series') || + changedProperties.has('horizontal') || + changedProperties.has('stacked') || + changedProperties.has('showLegend')) + ) { + this.updateChart(); + } + } + + protected buildOption(): Record { + const seriesColors = getEchartsSeriesColors(this.goBright); + const isDark = !this.goBright; + const formatter = this.valueFormatter; + + const categoryAxis: Record = { + type: 'category', + data: this.categories, + axisLine: { lineStyle: { color: isDark ? 'hsl(0 0% 20%)' : 'hsl(0 0% 85%)' } }, + axisLabel: { color: isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 40%)' }, + }; + + const valueAxis: Record = { + type: 'value', + axisLine: { show: false }, + axisLabel: { + color: isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 40%)', + formatter: (val: number) => formatter(val), + }, + splitLine: { lineStyle: { color: isDark ? 'hsl(0 0% 14%)' : 'hsl(0 0% 92%)' } }, + }; + + const seriesData = this.series.map((s, index) => ({ + name: s.name, + type: 'bar' as const, + data: s.data, + stack: this.stacked ? 'total' : undefined, + itemStyle: { + color: s.color || seriesColors[index % seriesColors.length], + borderRadius: this.stacked ? [0, 0, 0, 0] : this.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0], + }, + barMaxWidth: 40, + emphasis: { + itemStyle: { + shadowBlur: 6, + shadowColor: 'rgba(0, 0, 0, 0.15)', + }, + }, + })); + + // For stacked bars, round the top corners of the last visible series + if (this.stacked && seriesData.length > 0) { + const last = seriesData[seriesData.length - 1]; + last.itemStyle.borderRadius = this.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]; + } + + return { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + formatter: (params: any) => { + const items = Array.isArray(params) ? params : [params]; + let result = `${items[0].axisValueLabel}
`; + for (const p of items) { + result += `${p.marker} ${p.seriesName}: ${formatter(p.value)}
`; + } + return result; + }, + }, + legend: this.showLegend && this.series.length > 1 + ? { bottom: 8, itemWidth: 10, itemHeight: 10 } + : { show: false }, + grid: { + left: 16, + right: 16, + top: 16, + bottom: this.showLegend && this.series.length > 1 ? 40 : 16, + containLabel: true, + }, + xAxis: this.horizontal ? valueAxis : categoryAxis, + yAxis: this.horizontal ? categoryAxis : valueAxis, + series: seriesData, + }; + } +} diff --git a/ts_web/elements/00group-chart/dees-chart-bar/demo.ts b/ts_web/elements/00group-chart/dees-chart-bar/demo.ts new file mode 100644 index 0000000..ee3cc44 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-bar/demo.ts @@ -0,0 +1,120 @@ +import { html, css, cssManager } from '@design.estate/dees-element'; +import type { DeesChartBar } from './component.js'; +import '@design.estate/dees-wcctools/demotools'; +import './component.js'; + +export const demoFunc = () => { + const endpointCategories = ['/api/users', '/api/orders', '/api/products', '/api/auth', '/api/search']; + const endpointSeries = [ + { name: 'GET', data: [1240, 890, 720, 2100, 560] }, + { name: 'POST', data: [320, 450, 180, 890, 40] }, + { name: 'PUT', data: [90, 210, 150, 30, 10] }, + ]; + + const regionCategories = ['US-East', 'US-West', 'EU', 'Asia', 'Other']; + const regionSeries = [ + { name: 'Requests', data: [4500, 3200, 2800, 1900, 600] }, + ]; + + return html` + { + const vertChart = elementArg.querySelector('#vert-chart') as DeesChartBar; + const horizChart = elementArg.querySelector('#horiz-chart') as DeesChartBar; + const stackChart = elementArg.querySelector('#stack-chart') as DeesChartBar; + + const buttons = elementArg.querySelectorAll('dees-button'); + buttons.forEach((button: any) => { + const text = button.text?.trim(); + if (text === 'Randomize') { + button.addEventListener('click', () => { + vertChart.series = endpointSeries.map((s) => ({ + ...s, + data: s.data.map((v) => Math.round(v * (0.5 + Math.random()))), + })); + horizChart.series = regionSeries.map((s) => ({ + ...s, + data: s.data.map((v) => Math.round(v * (0.5 + Math.random()))), + })); + stackChart.series = endpointSeries.map((s) => ({ + ...s, + data: s.data.map((v) => Math.round(v * (0.5 + Math.random()))), + })); + }); + } + }); + }}> + +
+
+ + Randomize + +
+ +
+ `${val} req`} + > + + `${(val / 1000).toFixed(1)}k`} + > +
+ + `${val} req`} + > + +
+ Bar chart with vertical, horizontal, and stacked modes • + Click 'Randomize' to update data with animation +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-chart/dees-chart-bar/index.ts b/ts_web/elements/00group-chart/dees-chart-bar/index.ts new file mode 100644 index 0000000..16900c4 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-bar/index.ts @@ -0,0 +1 @@ +export * from './component.js'; diff --git a/ts_web/elements/00group-chart/dees-chart-bar/styles.ts b/ts_web/elements/00group-chart/dees-chart-bar/styles.ts new file mode 100644 index 0000000..0bce205 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-bar/styles.ts @@ -0,0 +1,7 @@ +import { css } from '@design.estate/dees-element'; +import { echartsBaseStyles } from '../dees-chart-echarts-styles.js'; + +export const barStyles = [ + ...echartsBaseStyles, + css``, +]; diff --git a/ts_web/elements/00group-chart/dees-chart-bar/template.ts b/ts_web/elements/00group-chart/dees-chart-bar/template.ts new file mode 100644 index 0000000..d0a5a8f --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-bar/template.ts @@ -0,0 +1,13 @@ +import { html, type TemplateResult } from '@design.estate/dees-element'; +import type { DeesChartBar } from './component.js'; + +export const renderChartBar = (component: DeesChartBar): TemplateResult => { + return html` + +
+ ${component.label} +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-chart/dees-chart-donut/component.ts b/ts_web/elements/00group-chart/dees-chart-donut/component.ts new file mode 100644 index 0000000..b8b9f93 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-donut/component.ts @@ -0,0 +1,129 @@ +import { + customElement, + property, + type TemplateResult, +} from '@design.estate/dees-element'; + +import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js'; +import { demoFunc } from './demo.js'; +import { donutStyles } from './styles.js'; +import { renderChartDonut } from './template.js'; +import { getEchartsSeriesColors } from '../dees-chart-echarts-theme.js'; + +export interface IDonutDataItem { + name: string; + value: number; + color?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'dees-chart-donut': DeesChartDonut; + } +} + +@customElement('dees-chart-donut') +export class DeesChartDonut extends DeesChartEchartsBase { + public static demo = demoFunc; + public static demoGroups = ['Chart']; + + @property({ type: Array }) + accessor data: IDonutDataItem[] = []; + + @property({ type: Boolean }) + accessor showLegend: boolean = true; + + @property({ type: Boolean }) + accessor showLabels: boolean = true; + + @property({ type: String }) + accessor innerRadiusPercent: string = '55%'; + + @property({ attribute: false }) + accessor valueFormatter: (value: number) => string = (val) => `${val}`; + + public static styles = donutStyles; + + public render(): TemplateResult { + return renderChartDonut(this); + } + + public async updated(changedProperties: Map) { + super.updated(changedProperties); + if ( + this.chartInstance && + (changedProperties.has('data') || + changedProperties.has('showLegend') || + changedProperties.has('showLabels') || + changedProperties.has('innerRadiusPercent')) + ) { + this.updateChart(); + } + } + + protected buildOption(): Record { + const seriesColors = getEchartsSeriesColors(this.goBright); + const data = this.data.map((item, index) => ({ + name: item.name, + value: item.value, + itemStyle: item.color ? { color: item.color } : { color: seriesColors[index % seriesColors.length] }, + })); + + const formatter = this.valueFormatter; + + return { + tooltip: { + trigger: 'item', + formatter: (params: any) => { + return `${params.marker} ${params.name}: ${formatter(params.value)} (${params.percent}%)`; + }, + }, + legend: this.showLegend + ? { + orient: 'vertical', + right: 16, + top: 'center', + itemWidth: 10, + itemHeight: 10, + itemGap: 12, + formatter: (name: string) => { + const item = this.data.find((d) => d.name === name); + return item ? `${name} ${formatter(item.value)}` : name; + }, + } + : { show: false }, + series: [ + { + type: 'pie', + radius: [this.innerRadiusPercent, '85%'], + center: this.showLegend ? ['35%', '50%'] : ['50%', '50%'], + avoidLabelOverlap: true, + itemStyle: { + borderRadius: 4, + borderColor: 'transparent', + borderWidth: 2, + }, + label: this.showLabels + ? { + show: true, + formatter: '{b}: {d}%', + fontSize: 11, + } + : { show: false }, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.2)', + }, + label: { + show: true, + fontWeight: 'bold', + }, + }, + data, + }, + ], + }; + } +} diff --git a/ts_web/elements/00group-chart/dees-chart-donut/demo.ts b/ts_web/elements/00group-chart/dees-chart-donut/demo.ts new file mode 100644 index 0000000..a787643 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-donut/demo.ts @@ -0,0 +1,127 @@ +import { html, css, cssManager } from '@design.estate/dees-element'; +import type { DeesChartDonut } from './component.js'; +import '@design.estate/dees-wcctools/demotools'; +import './component.js'; + +export const demoFunc = () => { + const diskData = [ + { name: 'Documents', value: 42 }, + { name: 'Media', value: 28 }, + { name: 'Applications', value: 15 }, + { name: 'System', value: 10 }, + { name: 'Other', value: 5 }, + ]; + + const statusData = [ + { name: 'Healthy', value: 156 }, + { name: 'Warning', value: 23 }, + { name: 'Critical', value: 8 }, + { name: 'Unknown', value: 3 }, + ]; + + const trafficData = [ + { name: 'API', value: 45200 }, + { name: 'Static Assets', value: 23100 }, + { name: 'WebSocket', value: 12800 }, + { name: 'GraphQL', value: 8900 }, + ]; + + return html` + { + const diskChart = elementArg.querySelector('#disk-chart') as DeesChartDonut; + const statusChart = elementArg.querySelector('#status-chart') as DeesChartDonut; + const trafficChart = elementArg.querySelector('#traffic-chart') as DeesChartDonut; + + // Wire up buttons + const buttons = elementArg.querySelectorAll('dees-button'); + buttons.forEach((button: any) => { + const text = button.text?.trim(); + if (text === 'Randomize') { + button.addEventListener('click', () => { + diskChart.data = diskData.map((d) => ({ + ...d, + value: Math.round(d.value * (0.5 + Math.random())), + })); + statusChart.data = statusData.map((d) => ({ + ...d, + value: Math.round(d.value * (0.3 + Math.random() * 1.4)), + })); + trafficChart.data = trafficData.map((d) => ({ + ...d, + value: Math.round(d.value * (0.5 + Math.random())), + })); + }); + } + }); + }}> + +
+
+ + Randomize + +
+ +
+ `${val} GB`} + > + + `${val} services`} + .innerRadiusPercent=${'0%'} + > +
+ + `${(val / 1000).toFixed(1)}k req`} + > + +
+ Donut chart with configurable inner radius (set to 0% for full pie) • + Click 'Randomize' to update data with animation +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-chart/dees-chart-donut/index.ts b/ts_web/elements/00group-chart/dees-chart-donut/index.ts new file mode 100644 index 0000000..16900c4 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-donut/index.ts @@ -0,0 +1 @@ +export * from './component.js'; diff --git a/ts_web/elements/00group-chart/dees-chart-donut/styles.ts b/ts_web/elements/00group-chart/dees-chart-donut/styles.ts new file mode 100644 index 0000000..ca4fa80 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-donut/styles.ts @@ -0,0 +1,11 @@ +import { css, cssManager } from '@design.estate/dees-element'; +import { echartsBaseStyles } from '../dees-chart-echarts-styles.js'; + +export const donutStyles = [ + ...echartsBaseStyles, + css` + :host { + height: 360px; + } + `, +]; diff --git a/ts_web/elements/00group-chart/dees-chart-donut/template.ts b/ts_web/elements/00group-chart/dees-chart-donut/template.ts new file mode 100644 index 0000000..ea10c41 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-donut/template.ts @@ -0,0 +1,13 @@ +import { html, type TemplateResult } from '@design.estate/dees-element'; +import type { DeesChartDonut } from './component.js'; + +export const renderChartDonut = (component: DeesChartDonut): TemplateResult => { + return html` + +
+ ${component.label} +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-chart/dees-chart-echarts-base.ts b/ts_web/elements/00group-chart/dees-chart-echarts-base.ts new file mode 100644 index 0000000..72e4f5d --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-echarts-base.ts @@ -0,0 +1,107 @@ +import { + DeesElement, + property, + html, + type TemplateResult, +} from '@design.estate/dees-element'; + +import * as domtools from '@design.estate/dees-domtools'; +import { DeesServiceLibLoader, type IEchartsBundle, type IEchartsInstance } from '../../services/index.js'; +import { getEchartsThemeOptions } from './dees-chart-echarts-theme.js'; +import '../00group-layout/dees-tile/dees-tile.js'; + +/** + * Abstract base class for ECharts-based chart components. + * Handles library loading, chart lifecycle, resize observation, and theme switching. + * Subclasses implement `buildOption()` to define their chart configuration. + */ +export abstract class DeesChartEchartsBase extends DeesElement { + @property() + accessor label: string = 'Untitled Chart'; + + protected chartInstance: IEchartsInstance | null = null; + protected echartsBundle: IEchartsBundle | null = null; + private resizeObserver: ResizeObserver | null = null; + + constructor() { + super(); + domtools.elementBasic.setup(); + this.registerGarbageFunction(async () => { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + if (this.chartInstance) { + try { + this.chartInstance.dispose(); + this.chartInstance = null; + } catch (e) { + console.error('Error disposing ECharts instance:', e); + } + } + }); + } + + public render(): TemplateResult { + return html` + +
+ ${this.label} +
+
+
+ `; + } + + public async firstUpdated() { + await this.domtoolsPromise; + this.echartsBundle = await DeesServiceLibLoader.getInstance().loadEcharts(); + await new Promise(resolve => requestAnimationFrame(resolve)); + + const chartContainer = this.shadowRoot!.querySelector('.chartContainer') as HTMLDivElement; + if (!chartContainer) return; + + try { + this.chartInstance = this.echartsBundle.init(chartContainer); + this.updateChart(); + + this.resizeObserver = new ResizeObserver(() => { + this.chartInstance?.resize(); + }); + this.resizeObserver.observe(chartContainer); + } catch (error) { + console.error('Failed to initialize ECharts:', error); + } + } + + public async updated(changedProperties: Map) { + super.updated(changedProperties); + if (changedProperties.has('goBright') && this.chartInstance) { + this.applyTheme(); + } + } + + protected abstract buildOption(): Record; + + protected updateChart(): void { + if (!this.chartInstance) return; + const themeOptions = getEchartsThemeOptions(this.goBright); + const chartOption = this.buildOption(); + // Merge theme defaults with chart-specific options + const merged = { + ...themeOptions, + ...chartOption, + textStyle: { ...themeOptions.textStyle, ...(chartOption.textStyle || {}) }, + tooltip: { ...themeOptions.tooltip, ...(chartOption.tooltip || {}) }, + }; + this.chartInstance.setOption(merged, true); + } + + protected applyTheme(): void { + this.updateChart(); + } + + public async forceResize(): Promise { + this.chartInstance?.resize(); + } +} diff --git a/ts_web/elements/00group-chart/dees-chart-echarts-styles.ts b/ts_web/elements/00group-chart/dees-chart-echarts-styles.ts new file mode 100644 index 0000000..ff9579f --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-echarts-styles.ts @@ -0,0 +1,36 @@ +import { css, cssManager } from '@design.estate/dees-element'; +import { themeDefaultStyles } from '../00theme.js'; + +export const echartsBaseStyles = [ + themeDefaultStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + height: 400px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + color: var(--dees-color-text-primary); + font-size: 14px; + } + dees-tile { + height: 100%; + } + .chartHeader { + display: flex; + align-items: center; + height: 32px; + padding: 0 8px 0 16px; + } + .chartLabel { + flex: 1; + font-size: 14px; + font-weight: 500; + letter-spacing: -0.01em; + color: var(--dees-color-text-secondary); + } + .chartContainer { + position: absolute; + inset: 0; + } + `, +]; diff --git a/ts_web/elements/00group-chart/dees-chart-echarts-theme.ts b/ts_web/elements/00group-chart/dees-chart-echarts-theme.ts new file mode 100644 index 0000000..d776d98 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-echarts-theme.ts @@ -0,0 +1,55 @@ +/** + * Shared theme utilities for ECharts-based chart components. + * Provides color palettes and option fragments that match the dees-catalog design tokens. + */ + +const SERIES_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(142 76% 36%)', // green + 'hsl(346 77% 49%)', // rose + ], + 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%)', + 'hsl(160 60% 45%)', + 'hsl(340 65% 47%)', + ], +}; + +export function getEchartsSeriesColors(goBright: boolean): string[] { + return goBright ? SERIES_COLORS.light : SERIES_COLORS.dark; +} + +export function getEchartsThemeOptions(goBright: boolean): Record { + const isDark = !goBright; + return { + backgroundColor: 'transparent', + textStyle: { + color: isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + fontSize: 12, + }, + color: goBright ? SERIES_COLORS.light : SERIES_COLORS.dark, + tooltip: { + backgroundColor: isDark ? 'hsl(0 0% 9%)' : 'hsl(0 0% 100%)', + borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 89.8%)', + textStyle: { + color: isDark ? 'hsl(0 0% 95%)' : 'hsl(0 0% 9%)', + fontSize: 12, + }, + confine: true, + }, + legend: { + textStyle: { + color: isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)', + fontSize: 12, + }, + }, + }; +} diff --git a/ts_web/elements/00group-chart/dees-chart-gauge/component.ts b/ts_web/elements/00group-chart/dees-chart-gauge/component.ts new file mode 100644 index 0000000..d8770e2 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-gauge/component.ts @@ -0,0 +1,161 @@ +import { + customElement, + property, + type TemplateResult, +} from '@design.estate/dees-element'; + +import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js'; +import { demoFunc } from './demo.js'; +import { gaugeStyles } from './styles.js'; +import { renderChartGauge } from './template.js'; +import { getEchartsSeriesColors } from '../dees-chart-echarts-theme.js'; + +export interface IGaugeThreshold { + value: number; + color: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'dees-chart-gauge': DeesChartGauge; + } +} + +@customElement('dees-chart-gauge') +export class DeesChartGauge extends DeesChartEchartsBase { + public static demo = demoFunc; + public static demoGroups = ['Chart']; + + @property({ type: Number }) + accessor value: number = 0; + + @property({ type: Number }) + accessor min: number = 0; + + @property({ type: Number }) + accessor max: number = 100; + + @property({ type: String }) + accessor unit: string = '%'; + + @property({ type: Array }) + accessor thresholds: IGaugeThreshold[] = []; + + @property({ type: Boolean }) + accessor showTicks: boolean = true; + + public static styles = gaugeStyles; + + public render(): TemplateResult { + return renderChartGauge(this); + } + + public async updated(changedProperties: Map) { + super.updated(changedProperties); + if ( + this.chartInstance && + (changedProperties.has('value') || + changedProperties.has('min') || + changedProperties.has('max') || + changedProperties.has('unit') || + changedProperties.has('thresholds') || + changedProperties.has('showTicks')) + ) { + this.updateChart(); + } + } + + protected buildOption(): Record { + const isDark = !this.goBright; + const seriesColors = getEchartsSeriesColors(this.goBright); + const primaryColor = seriesColors[0]; + + // Build axis line color stops from thresholds + let axisLineColors: Array<[number, string]>; + if (this.thresholds.length > 0) { + const sorted = [...this.thresholds].sort((a, b) => a.value - b.value); + axisLineColors = sorted.map((t) => [ + (t.value - this.min) / (this.max - this.min), + t.color, + ]); + // Ensure we end at 1 + if (axisLineColors[axisLineColors.length - 1][0] < 1) { + axisLineColors.push([1, sorted[sorted.length - 1].color]); + } + } else { + axisLineColors = [[1, primaryColor]]; + } + + return { + series: [ + { + type: 'gauge', + min: this.min, + max: this.max, + startAngle: 220, + endAngle: -40, + progress: { + show: true, + width: 14, + roundCap: true, + }, + pointer: { + show: true, + length: '60%', + width: 5, + itemStyle: { + color: 'auto', + }, + }, + axisLine: { + lineStyle: { + width: 14, + color: axisLineColors, + opacity: 0.3, + }, + }, + axisTick: { + show: this.showTicks, + distance: -20, + length: 6, + lineStyle: { + color: isDark ? 'hsl(0 0% 30%)' : 'hsl(0 0% 75%)', + width: 1, + }, + }, + splitLine: { + show: this.showTicks, + distance: -24, + length: 10, + lineStyle: { + color: isDark ? 'hsl(0 0% 40%)' : 'hsl(0 0% 60%)', + width: 2, + }, + }, + axisLabel: { + show: this.showTicks, + distance: 30, + color: isDark ? 'hsl(0 0% 50%)' : 'hsl(0 0% 45%)', + fontSize: 11, + }, + detail: { + valueAnimation: true, + fontSize: 28, + fontWeight: 600, + offsetCenter: [0, '65%'], + color: isDark ? 'hsl(0 0% 90%)' : 'hsl(0 0% 15%)', + formatter: `{value}${this.unit}`, + }, + title: { + show: false, + }, + data: [ + { + value: this.value, + }, + ], + }, + ], + }; + } +} diff --git a/ts_web/elements/00group-chart/dees-chart-gauge/demo.ts b/ts_web/elements/00group-chart/dees-chart-gauge/demo.ts new file mode 100644 index 0000000..5b48c7e --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-gauge/demo.ts @@ -0,0 +1,125 @@ +import { html, css, cssManager } from '@design.estate/dees-element'; +import type { DeesChartGauge } from './component.js'; +import '@design.estate/dees-wcctools/demotools'; +import './component.js'; + +export const demoFunc = () => { + const defaultThresholds = [ + { value: 60, color: 'hsl(142 76% 36%)' }, + { value: 80, color: 'hsl(38 92% 50%)' }, + { value: 100, color: 'hsl(0 72% 50%)' }, + ]; + + return html` + { + const cpuGauge = elementArg.querySelector('#cpu-gauge') as DeesChartGauge; + const memGauge = elementArg.querySelector('#mem-gauge') as DeesChartGauge; + const slaGauge = elementArg.querySelector('#sla-gauge') as DeesChartGauge; + + let animInterval: number | null = null; + + const buttons = elementArg.querySelectorAll('dees-button'); + buttons.forEach((button: any) => { + const text = button.text?.trim(); + if (text === 'Animate') { + button.addEventListener('click', () => { + if (animInterval) return; + animInterval = window.setInterval(() => { + cpuGauge.value = Math.round(30 + Math.random() * 60); + memGauge.value = Math.round(40 + Math.random() * 50); + slaGauge.value = Math.round((95 + Math.random() * 5) * 100) / 100; + }, 2000); + }); + } else if (text === 'Stop') { + button.addEventListener('click', () => { + if (animInterval) { + window.clearInterval(animInterval); + animInterval = null; + } + }); + } else if (text === 'Spike') { + button.addEventListener('click', () => { + cpuGauge.value = 95; + memGauge.value = 88; + slaGauge.value = 96.5; + }); + } + }); + }}> + +
+
+ + Animate + Stop + Spike + +
+ +
+ + + + + +
+ +
+ Gauge chart with animated value transitions and threshold coloring • + Click 'Animate' for live updates, 'Spike' to simulate high load +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-chart/dees-chart-gauge/index.ts b/ts_web/elements/00group-chart/dees-chart-gauge/index.ts new file mode 100644 index 0000000..16900c4 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-gauge/index.ts @@ -0,0 +1 @@ +export * from './component.js'; diff --git a/ts_web/elements/00group-chart/dees-chart-gauge/styles.ts b/ts_web/elements/00group-chart/dees-chart-gauge/styles.ts new file mode 100644 index 0000000..8124447 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-gauge/styles.ts @@ -0,0 +1,11 @@ +import { css } from '@design.estate/dees-element'; +import { echartsBaseStyles } from '../dees-chart-echarts-styles.js'; + +export const gaugeStyles = [ + ...echartsBaseStyles, + css` + :host { + height: 320px; + } + `, +]; diff --git a/ts_web/elements/00group-chart/dees-chart-gauge/template.ts b/ts_web/elements/00group-chart/dees-chart-gauge/template.ts new file mode 100644 index 0000000..4555254 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-gauge/template.ts @@ -0,0 +1,13 @@ +import { html, type TemplateResult } from '@design.estate/dees-element'; +import type { DeesChartGauge } from './component.js'; + +export const renderChartGauge = (component: DeesChartGauge): TemplateResult => { + return html` + +
+ ${component.label} +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-chart/dees-chart-radar/component.ts b/ts_web/elements/00group-chart/dees-chart-radar/component.ts new file mode 100644 index 0000000..d455dc6 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-radar/component.ts @@ -0,0 +1,132 @@ +import { + customElement, + property, + type TemplateResult, +} from '@design.estate/dees-element'; + +import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js'; +import { demoFunc } from './demo.js'; +import { radarStyles } from './styles.js'; +import { renderChartRadar } from './template.js'; +import { getEchartsSeriesColors } from '../dees-chart-echarts-theme.js'; + +export interface IRadarIndicator { + name: string; + max: number; +} + +export interface IRadarSeriesItem { + name: string; + values: number[]; + color?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'dees-chart-radar': DeesChartRadar; + } +} + +@customElement('dees-chart-radar') +export class DeesChartRadar extends DeesChartEchartsBase { + public static demo = demoFunc; + public static demoGroups = ['Chart']; + + @property({ type: Array }) + accessor indicators: IRadarIndicator[] = []; + + @property({ type: Array }) + accessor series: IRadarSeriesItem[] = []; + + @property({ type: Boolean }) + accessor showLegend: boolean = true; + + @property({ type: Boolean }) + accessor fillArea: boolean = true; + + public static styles = radarStyles; + + public render(): TemplateResult { + return renderChartRadar(this); + } + + public async updated(changedProperties: Map) { + super.updated(changedProperties); + if ( + this.chartInstance && + (changedProperties.has('indicators') || + changedProperties.has('series') || + changedProperties.has('showLegend') || + changedProperties.has('fillArea')) + ) { + this.updateChart(); + } + } + + protected buildOption(): Record { + const isDark = !this.goBright; + const seriesColors = getEchartsSeriesColors(this.goBright); + + const seriesData = this.series.map((s, index) => { + const color = s.color || seriesColors[index % seriesColors.length]; + return { + name: s.name, + value: s.values, + itemStyle: { color }, + lineStyle: { color, width: 2 }, + areaStyle: this.fillArea ? { color, opacity: 0.15 } : undefined, + symbol: 'circle', + symbolSize: 6, + }; + }); + + return { + tooltip: { + trigger: 'item', + }, + legend: this.showLegend && this.series.length > 1 + ? { bottom: 8, itemWidth: 10, itemHeight: 10 } + : { show: false }, + radar: { + indicator: this.indicators.map((ind) => ({ + name: ind.name, + max: ind.max, + })), + shape: 'polygon', + splitNumber: 4, + axisName: { + color: isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 35%)', + fontSize: 11, + }, + splitArea: { + areaStyle: { + color: isDark + ? ['hsl(0 0% 7%)', 'hsl(0 0% 9%)'] + : ['hsl(0 0% 97%)', 'hsl(0 0% 95%)'], + }, + }, + splitLine: { + lineStyle: { + color: isDark ? 'hsl(0 0% 16%)' : 'hsl(0 0% 88%)', + }, + }, + axisLine: { + lineStyle: { + color: isDark ? 'hsl(0 0% 16%)' : 'hsl(0 0% 88%)', + }, + }, + }, + series: [ + { + type: 'radar', + data: seriesData, + emphasis: { + lineStyle: { + width: 3, + }, + }, + }, + ], + }; + } +} diff --git a/ts_web/elements/00group-chart/dees-chart-radar/demo.ts b/ts_web/elements/00group-chart/dees-chart-radar/demo.ts new file mode 100644 index 0000000..393104c --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-radar/demo.ts @@ -0,0 +1,119 @@ +import { html, css, cssManager } from '@design.estate/dees-element'; +import type { DeesChartRadar } from './component.js'; +import '@design.estate/dees-wcctools/demotools'; +import './component.js'; + +export const demoFunc = () => { + const indicators = [ + { name: 'Latency', max: 100 }, + { name: 'Throughput', max: 100 }, + { name: 'Availability', max: 100 }, + { name: 'Error Rate', max: 100 }, + { name: 'Saturation', max: 100 }, + { name: 'Security', max: 100 }, + ]; + + const series = [ + { name: 'Service A', values: [85, 90, 99, 12, 45, 78] }, + { name: 'Service B', values: [70, 65, 95, 28, 60, 90] }, + ]; + + const singleIndicators = [ + { name: 'Speed', max: 10 }, + { name: 'Reliability', max: 10 }, + { name: 'Comfort', max: 10 }, + { name: 'Safety', max: 10 }, + { name: 'Cost', max: 10 }, + ]; + + const singleSeries = [ + { name: 'Rating', values: [8.5, 9.2, 7.0, 9.5, 6.0] }, + ]; + + return html` + { + const compChart = elementArg.querySelector('#comparison-chart') as DeesChartRadar; + const singleChart = elementArg.querySelector('#single-chart') as DeesChartRadar; + + const buttons = elementArg.querySelectorAll('dees-button'); + buttons.forEach((button: any) => { + const text = button.text?.trim(); + if (text === 'Randomize') { + button.addEventListener('click', () => { + compChart.series = series.map((s) => ({ + ...s, + values: s.values.map(() => Math.round(20 + Math.random() * 80)), + })); + singleChart.series = singleSeries.map((s) => ({ + ...s, + values: s.values.map(() => Math.round((2 + Math.random() * 8) * 10) / 10), + })); + }); + } + }); + }}> + +
+
+ + Randomize + +
+ +
+ + + +
+ +
+ Radar chart for multi-dimensional comparison • + Supports multiple overlay series and configurable fill • + Click 'Randomize' to update data +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-chart/dees-chart-radar/index.ts b/ts_web/elements/00group-chart/dees-chart-radar/index.ts new file mode 100644 index 0000000..16900c4 --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-radar/index.ts @@ -0,0 +1 @@ +export * from './component.js'; diff --git a/ts_web/elements/00group-chart/dees-chart-radar/styles.ts b/ts_web/elements/00group-chart/dees-chart-radar/styles.ts new file mode 100644 index 0000000..cea020d --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-radar/styles.ts @@ -0,0 +1,7 @@ +import { css } from '@design.estate/dees-element'; +import { echartsBaseStyles } from '../dees-chart-echarts-styles.js'; + +export const radarStyles = [ + ...echartsBaseStyles, + css``, +]; diff --git a/ts_web/elements/00group-chart/dees-chart-radar/template.ts b/ts_web/elements/00group-chart/dees-chart-radar/template.ts new file mode 100644 index 0000000..2b2fe3a --- /dev/null +++ b/ts_web/elements/00group-chart/dees-chart-radar/template.ts @@ -0,0 +1,13 @@ +import { html, type TemplateResult } from '@design.estate/dees-element'; +import type { DeesChartRadar } from './component.js'; + +export const renderChartRadar = (component: DeesChartRadar): TemplateResult => { + return html` + +
+ ${component.label} +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-chart/index.ts b/ts_web/elements/00group-chart/index.ts index 58bf4ee..befe063 100644 --- a/ts_web/elements/00group-chart/index.ts +++ b/ts_web/elements/00group-chart/index.ts @@ -1,3 +1,7 @@ // Chart Components export * from './dees-chart-area/index.js'; +export * from './dees-chart-bar/index.js'; +export * from './dees-chart-donut/index.js'; +export * from './dees-chart-gauge/index.js'; export * from './dees-chart-log/index.js'; +export * from './dees-chart-radar/index.js'; diff --git a/ts_web/services/DeesServiceLibLoader.ts b/ts_web/services/DeesServiceLibLoader.ts index 6495f33..c976275 100644 --- a/ts_web/services/DeesServiceLibLoader.ts +++ b/ts_web/services/DeesServiceLibLoader.ts @@ -74,6 +74,28 @@ export interface ILightweightChartsBundle { }; } +/** + * Minimal type for an ECharts instance (loaded from CDN) + */ +export interface IEchartsInstance { + setOption(option: Record, notMerge?: boolean): void; + resize(opts?: { width?: number; height?: number }): void; + dispose(): void; + on(eventName: string, handler: (...args: any[]) => void): void; + off(eventName: string, handler?: (...args: any[]) => void): void; + getOption(): Record; + clear(): void; +} + +/** + * Bundle type for Apache ECharts + */ +export interface IEchartsBundle { + init: (dom: HTMLElement, theme?: string | object | null, opts?: Record) => IEchartsInstance; + dispose: (chart: IEchartsInstance | HTMLElement | string) => void; + getInstanceByDom: (dom: HTMLElement) => IEchartsInstance | undefined; +} + /** * Bundle type for Tiptap editor and extensions */ @@ -108,6 +130,7 @@ export class DeesServiceLibLoader { private xtermSearchAddonLib: IXtermSearchAddonBundle | null = null; private highlightJsLib: HLJSApi | null = null; private lightweightChartsLib: ILightweightChartsBundle | null = null; + private echartsLib: IEchartsBundle | null = null; private tiptapLib: ITiptapBundle | null = null; // Loading promises to prevent duplicate concurrent loads @@ -116,6 +139,7 @@ export class DeesServiceLibLoader { private xtermSearchAddonLoadingPromise: Promise | null = null; private highlightJsLoadingPromise: Promise | null = null; private lightweightChartsLoadingPromise: Promise | null = null; + private echartsLoadingPromise: Promise | null = null; private tiptapLoadingPromise: Promise | null = null; private constructor() {} @@ -296,6 +320,34 @@ body > div[style*="top: -50000px"][style*="width: 50000px"] { return this.lightweightChartsLoadingPromise; } + /** + * Load Apache ECharts from CDN + * @returns Promise resolving to ECharts bundle + */ + public async loadEcharts(): Promise { + if (this.echartsLib) { + return this.echartsLib; + } + + if (this.echartsLoadingPromise) { + return this.echartsLoadingPromise; + } + + this.echartsLoadingPromise = (async () => { + const url = `${CDN_BASE}/echarts@${CDN_VERSIONS.echarts}/+esm`; + const module = await import(/* @vite-ignore */ url); + + this.echartsLib = { + init: module.init, + dispose: module.dispose, + getInstanceByDom: module.getInstanceByDom, + }; + return this.echartsLib; + })(); + + return this.echartsLoadingPromise; + } + /** * Load Tiptap rich text editor and extensions from CDN * @returns Promise resolving to Tiptap bundle with Editor and extensions @@ -348,6 +400,7 @@ body > div[style*="top: -50000px"][style*="width: 50000px"] { this.loadXtermSearchAddon(), this.loadHighlightJs(), this.loadLightweightCharts(), + this.loadEcharts(), this.loadTiptap(), ]); } @@ -355,7 +408,7 @@ body > div[style*="top: -50000px"][style*="width: 50000px"] { /** * Check if a specific library is already loaded */ - public isLoaded(library: 'xterm' | 'xtermFitAddon' | 'xtermSearchAddon' | 'highlightJs' | 'lightweightCharts' | 'tiptap'): boolean { + public isLoaded(library: 'xterm' | 'xtermFitAddon' | 'xtermSearchAddon' | 'highlightJs' | 'lightweightCharts' | 'echarts' | 'tiptap'): boolean { switch (library) { case 'xterm': return this.xtermLib !== null; @@ -367,6 +420,8 @@ body > div[style*="top: -50000px"][style*="width: 50000px"] { return this.highlightJsLib !== null; case 'lightweightCharts': return this.lightweightChartsLib !== null; + case 'echarts': + return this.echartsLib !== null; case 'tiptap': return this.tiptapLib !== null; default: diff --git a/ts_web/services/index.ts b/ts_web/services/index.ts index 5098570..7e7a164 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, ILightweightChartsBundle } from './DeesServiceLibLoader.js'; +export type { IXtermBundle, IXtermFitAddonBundle, IXtermSearchAddonBundle, IXtermSearchAddon, ITiptapBundle, ILightweightChartsBundle, IEchartsBundle, IEchartsInstance } 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 bb950bf..1d03e2b 100644 --- a/ts_web/services/versions.ts +++ b/ts_web/services/versions.ts @@ -7,6 +7,7 @@ export const CDN_VERSIONS = { xtermAddonFit: '0.8.0', xtermAddonSearch: '0.13.0', highlightJs: '11.11.1', + echarts: '5.6.0', lightweightCharts: '5.1.0', tiptap: '2.27.2', fontawesome: '7.2.0',