597 lines
19 KiB
TypeScript
597 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 }> = [];
|
|
|
|
@state()
|
|
accessor isFullPage: boolean = false;
|
|
|
|
@property()
|
|
accessor label: string = 'Untitled Chart';
|
|
|
|
@property({ type: Array })
|
|
accessor series: ChartSeriesConfig = [];
|
|
|
|
get chartSeries(): ChartSeriesConfig {
|
|
return this.internalChartData.length > 0 ? this.internalChartData : this.series;
|
|
}
|
|
|
|
@property({ attribute: false })
|
|
accessor yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`;
|
|
|
|
@property({ type: Number })
|
|
accessor rollingWindow: number = 0;
|
|
|
|
@property({ type: Boolean })
|
|
accessor realtimeMode: boolean = false;
|
|
|
|
@property({ type: String })
|
|
accessor yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic';
|
|
|
|
@property({ type: Number })
|
|
accessor yAxisMax: number = 100;
|
|
|
|
@property({ type: Number })
|
|
accessor autoScrollInterval: number = 1000;
|
|
|
|
private internalChartData: ChartSeriesConfig = [];
|
|
private autoScrollTimer: number | null = null;
|
|
private lcBundle: ILightweightChartsBundle | null = null;
|
|
private seriesApis: Map<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;
|
|
}
|
|
}
|
|
}
|