Files
dees-catalog/ts_web/elements/00group-chart/dees-chart-area/component.ts

594 lines
19 KiB
TypeScript

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 }> = [];
@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<string, ISeriesApi<any>> = new Map();
private priceLines: Map<string, any[]> = 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<any>, 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 += `<div style="display:flex;align-items:center;gap:8px;margin:${idx > 0 ? '6px' : '0'} 0;">
<span style="display:inline-block;width:10px;height:10px;background:${color};border-radius:2px;"></span>
<span style="font-weight:500;">${name}:</span>
<span style="margin-left:auto;font-weight:600;">${formatted}</span>
</div>`;
}
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<string, any>) {
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<string, any>) {
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();
}
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;
}
}
}