UI: Refactor range chart widget to use time series chart class.

This commit is contained in:
Igor Kulikov 2024-03-27 20:21:28 +02:00
parent 7900d5f29c
commit 2fb604e398
3 changed files with 193 additions and 238 deletions

View File

@ -32,48 +32,38 @@ import {
backgroundStyle,
ColorRange,
ComponentStyle,
DateFormatProcessor,
filterIncludingColorRanges,
getDataKey,
overlayStyle,
sortedColorRange,
textStyle
} from '@shared/models/widget-settings.models';
import { ResizeObserver } from '@juggle/resize-observer';
import * as echarts from 'echarts/core';
import { formatValue, isDefinedAndNotNull, isNumber } from '@core/utils';
import { isDefinedAndNotNull, isNumber } from '@core/utils';
import { rangeChartDefaultSettings, RangeChartWidgetSettings } from './range-chart-widget.models';
import { Observable } from 'rxjs';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
import { DeepPartial } from '@shared/models/common';
import {
ECharts,
echartsModule,
EChartsOption,
echartsTooltipFormatter, timeAxisBandWidthCalculator,
toNamedData
} from '@home/components/widget/lib/chart/echarts-widget.models';
import { CallbackDataParams } from 'echarts/types/dist/shared';
import { AggregationType } from '@shared/models/time/time.models';
interface VisualPiece {
lt?: number;
gt?: number;
lte?: number;
gte?: number;
value?: number;
color?: string;
}
createTimeSeriesChartVisualMapPiece,
SeriesFillType,
TimeSeriesChartKeySettings,
TimeSeriesChartSettings,
TimeSeriesChartShape,
TimeSeriesChartThreshold,
TimeSeriesChartThresholdType, TimeSeriesChartVisualMapPiece
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { TbTimeSeriesChart } from '@home/components/widget/lib/chart/time-series-chart';
interface RangeItem {
index: number;
from?: number;
to?: number;
piece: VisualPiece;
color: string;
label: string;
visible: boolean;
enabled: boolean;
piece: TimeSeriesChartVisualMapPiece;
}
const rangeItemLabel = (from?: number, to?: number): string => {
@ -92,25 +82,6 @@ const rangeItemLabel = (from?: number, to?: number): string => {
}
};
const toVisualPiece = (color: string, from?: number, to?: number): VisualPiece => {
const piece: VisualPiece = {
color
};
if (isNumber(from) && isNumber(to)) {
if (from === to) {
piece.value = from;
} else {
piece.gte = from;
piece.lt = to;
}
} else if (isNumber(from)) {
piece.gte = from;
} else if (isNumber(to)) {
piece.lt = to;
}
return piece;
};
const toRangeItems = (colorRanges: Array<ColorRange>): RangeItem[] => {
const rangeItems: RangeItem[] = [];
let counter = 0;
@ -134,7 +105,7 @@ const toRangeItems = (colorRanges: Array<ColorRange>): RangeItem[] => {
from,
to,
label: rangeItemLabel(from, to),
piece: toVisualPiece(range.color, from, to)
piece: createTimeSeriesChartVisualMapPiece(range.color, from, to)
}
);
if (!isNumber(from) || !isNumber(to)) {
@ -198,23 +169,12 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn
disabledLegendLabelStyle: ComponentStyle;
visibleRangeItems: RangeItem[];
private get noAggregation(): boolean {
return this.ctx.defaultSubscription.timeWindowConfig?.aggregation?.type === AggregationType.NONE;
}
private rangeItems: RangeItem[];
private shapeResize$: ResizeObserver;
private decimals = 0;
private units = '';
private drawChartPending = false;
private rangeChart: ECharts;
private rangeChartOptions: EChartsOption;
private selectedRanges: {[key: number]: boolean} = {};
private rangeItems: RangeItem[];
private tooltipDateFormat: DateFormatProcessor;
private timeSeriesChart: TbTimeSeriesChart;
constructor(private imagePipe: ImagePipe,
private sanitizer: DomSanitizer,
@ -235,16 +195,25 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn
if (dataKey?.units) {
this.units = dataKey.units;
}
if (dataKey) {
dataKey.settings = {
type: 'line',
lineSettings: {
showLine: true,
smooth: false,
showPoints: false,
fillAreaSettings: {
type: this.settings.fillArea ? 'default' : SeriesFillType.none
}
}
} as DeepPartial<TimeSeriesChartKeySettings>;
}
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.rangeItems = toRangeItems(this.settings.rangeColors);
this.visibleRangeItems = this.rangeItems.filter(item => item.visible);
for (const range of this.rangeItems) {
this.selectedRanges[range.index] = true;
}
this.showLegend = this.settings.showLegend && !!this.rangeItems.length;
@ -254,193 +223,90 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn
this.disabledLegendLabelStyle = textStyle(this.settings.legendLabelFont);
this.legendLabelStyle.color = this.settings.legendLabelColor;
}
if (this.settings.showTooltip && this.settings.tooltipShowDate) {
this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat);
}
}
ngAfterViewInit() {
if (this.drawChartPending) {
this.drawChart();
const thresholds: DeepPartial<TimeSeriesChartThreshold>[] = getMarkPoints(this.rangeItems).map(item => ({
type: TimeSeriesChartThresholdType.constant,
yAxisId: 'default',
units: this.units,
decimals: this.decimals,
lineWidth: 1,
lineColor: '#37383b',
lineType: [3, 3],
startSymbol: TimeSeriesChartShape.circle,
startSymbolSize: 5,
endSymbol: TimeSeriesChartShape.arrow,
endSymbolSize: 7,
showLabel: true,
labelPosition: 'insideEndTop',
labelColor: '#37383b',
additionalLabelOption: {
backgroundColor: 'rgba(255,255,255,0.56)',
padding: [4, 5],
borderRadius: 4,
},
value: item
} as DeepPartial<TimeSeriesChartThreshold>));
const settings: DeepPartial<TimeSeriesChartSettings> = {
dataZoom: this.settings.dataZoom,
thresholds,
yAxes: {
default: {
show: true,
showLine: false,
showTicks: false,
showTickLabels: true,
showSplitLines: true,
decimals: this.decimals,
units: this.units
}
},
xAxis: {
show: true,
showLine: true,
showTicks: true,
showTickLabels: true,
showSplitLines: false
},
visualMapSettings: {
outOfRangeColor: this.settings.outOfRangeColor,
pieces: this.rangeItems.map(item => item.piece)
},
showTooltip: this.settings.showTooltip,
tooltipValueFont: this.settings.tooltipValueFont,
tooltipValueColor: this.settings.tooltipValueColor,
tooltipShowDate: this.settings.tooltipShowDate,
tooltipDateInterval: this.settings.tooltipDateInterval,
tooltipDateFormat: this.settings.tooltipDateFormat,
tooltipDateFont: this.settings.tooltipDateFont,
tooltipDateColor: this.settings.tooltipDateColor,
tooltipBackgroundColor: this.settings.tooltipBackgroundColor,
tooltipBackgroundBlur: this.settings.tooltipBackgroundBlur,
};
this.timeSeriesChart = new TbTimeSeriesChart(this.ctx, settings, this.chartShape.nativeElement, this.renderer);
}
ngOnDestroy() {
if (this.shapeResize$) {
this.shapeResize$.disconnect();
}
if (this.rangeChart) {
this.rangeChart.dispose();
if (this.timeSeriesChart) {
this.timeSeriesChart.destroy();
}
}
public onInit() {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
if (this.chartShape) {
this.drawChart();
} else {
this.drawChartPending = true;
}
this.cd.detectChanges();
}
public onDataUpdated() {
if (this.rangeChart) {
this.rangeChart.setOption({
xAxis: {
min: this.ctx.defaultSubscription.timeWindow.minTime,
max: this.ctx.defaultSubscription.timeWindow.maxTime,
tbTimeWindow: this.ctx.defaultSubscription.timeWindow
},
series: [
{data: this.ctx.data?.length ? toNamedData(this.ctx.data[0].data) : []}
],
visualMap: {
selected: this.selectedRanges
}
});
if (this.timeSeriesChart) {
this.timeSeriesChart.update();
}
}
public toggleRangeItem(item: RangeItem) {
item.enabled = !item.enabled;
this.selectedRanges[item.index] = item.enabled;
this.rangeChart.dispatchAction({
type: 'selectDataRange',
selected: this.selectedRanges
});
}
private drawChart() {
echartsModule.init();
const dataKey = getDataKey(this.ctx.datasources);
this.rangeChart = echarts.init(this.chartShape.nativeElement, null, {
renderer: 'canvas',
});
this.rangeChartOptions = {
tooltip: {
trigger: 'axis',
confine: true,
appendTo: 'body',
axisPointer: {
type: 'shadow'
},
formatter: (params: CallbackDataParams[]) =>
this.settings.showTooltip ? echartsTooltipFormatter(this.renderer, this.tooltipDateFormat,
this.settings, params, this.decimals, this.units, 0, null,
this.noAggregation ? null : this.ctx.timeWindow.interval) : undefined,
padding: [8, 12],
backgroundColor: this.settings.tooltipBackgroundColor,
borderWidth: 0,
extraCssText: `line-height: 1; backdrop-filter: blur(${this.settings.tooltipBackgroundBlur}px);`
},
grid: {
containLabel: true,
top: '30',
left: 0,
right: 0,
bottom: this.settings.dataZoom ? 60 : 0
},
xAxis: {
type: 'time',
axisTick: {
show: true
},
axisLabel: {
hideOverlap: true,
fontSize: 10
},
axisLine: {
onZero: false
},
min: this.ctx.defaultSubscription.timeWindow.minTime,
max: this.ctx.defaultSubscription.timeWindow.maxTime,
bandWidthCalculator: timeAxisBandWidthCalculator
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value: any) => formatValue(value, this.decimals, this.units, false)
}
},
series: [{
type: 'line',
name: dataKey?.label,
smooth: false,
showSymbol: false,
animation: true,
areaStyle: this.settings.fillArea ? {} : undefined,
data: this.ctx.data?.length ? toNamedData(this.ctx.data[0].data) : [],
markLine: this.rangeItems.length ? {
animation: true,
symbol: ['circle', 'arrow'],
symbolSize: [5, 7],
lineStyle: {
width: 1,
type: [3, 3],
color: '#37383b'
},
label: {
position: 'insideEndTop',
color: '#37383b',
backgroundColor: 'rgba(255,255,255,0.56)',
padding: [4, 5],
borderRadius: 4,
formatter: params => formatValue(params.value, this.decimals, this.units, false)
},
emphasis: {
disabled: true
},
data: getMarkPoints(this.rangeItems).map(point => ({ yAxis: point }))
} : undefined
}],
dataZoom: [
{
type: 'inside',
disabled: !this.settings.dataZoom
},
{
type: 'slider',
show: this.settings.dataZoom,
showDetail: false,
right: 10
}
],
visualMap: {
show: false,
type: 'piecewise',
selected: this.selectedRanges,
dimension: 1,
pieces: this.rangeItems.map(item => item.piece),
outOfRange: {
color: this.settings.outOfRangeColor
},
inRange: !this.rangeItems.length ? {
color: this.settings.outOfRangeColor
} : undefined
}
};
(this.rangeChartOptions.xAxis as any).tbTimeWindow = this.ctx.defaultSubscription.timeWindow;
this.rangeChart.setOption(this.rangeChartOptions);
this.shapeResize$ = new ResizeObserver(() => {
this.onResize();
});
this.shapeResize$.observe(this.chartShape.nativeElement);
this.onResize();
}
private onResize() {
const width = this.rangeChart.getWidth();
const height = this.rangeChart.getHeight();
const shapeWidth = this.chartShape.nativeElement.offsetWidth;
const shapeHeight = this.chartShape.nativeElement.offsetHeight;
if (width !== shapeWidth || height !== shapeHeight) {
this.rangeChart.resize();
}
this.timeSeriesChart.toggleVisualMapRange(item.index);
}
}

View File

@ -31,12 +31,17 @@ import {
textStyle,
tsToFormatTimeUnit
} from '@shared/models/widget-settings.models';
import { LabelLayoutOptionCallback, XAXisOption, YAXisOption } from 'echarts/types/dist/shared';
import {
LabelLayoutOptionCallback,
VisualMapComponentOption,
XAXisOption,
YAXisOption
} from 'echarts/types/dist/shared';
import { CustomSeriesOption, LineSeriesOption } from 'echarts/charts';
import {
formatValue,
isDefinedAndNotNull,
isFunction,
isFunction, isNumber,
isNumeric,
isUndefined,
isUndefinedOrNull,
@ -417,7 +422,7 @@ export interface TimeSeriesChartThreshold {
units?: string;
decimals?: number;
lineColor: string;
lineType: TimeSeriesChartLineType;
lineType: TimeSeriesChartLineType | number | number[];
lineWidth: number;
startSymbol: TimeSeriesChartShape;
startSymbolSize: number;
@ -427,6 +432,7 @@ export interface TimeSeriesChartThreshold {
labelPosition: ThresholdLabelPosition;
labelFont: Font;
labelColor: string;
additionalLabelOption?: {[key: string]: any};
}
export const timeSeriesChartThresholdValid = (threshold: TimeSeriesChartThreshold): boolean => {
@ -567,6 +573,39 @@ export interface TimeSeriesChartAnimationSettings {
animationDelayUpdate: number;
}
export interface TimeSeriesChartVisualMapPiece {
lt?: number;
gt?: number;
lte?: number;
gte?: number;
value?: number;
color?: string;
}
export const createTimeSeriesChartVisualMapPiece = (color: string, from?: number, to?: number): TimeSeriesChartVisualMapPiece => {
const piece: TimeSeriesChartVisualMapPiece = {
color
};
if (isNumber(from) && isNumber(to)) {
if (from === to) {
piece.value = from;
} else {
piece.gte = from;
piece.lt = to;
}
} else if (isNumber(from)) {
piece.gte = from;
} else if (isNumber(to)) {
piece.lt = to;
}
return piece;
};
export interface TimeSeriesChartVisualMapSettings {
outOfRangeColor: string;
pieces: TimeSeriesChartVisualMapPiece[];
}
export interface TimeSeriesChartSettings extends EChartsTooltipWidgetSettings {
thresholds: TimeSeriesChartThreshold[];
darkMode: boolean;
@ -577,6 +616,7 @@ export interface TimeSeriesChartSettings extends EChartsTooltipWidgetSettings {
animation: TimeSeriesChartAnimationSettings;
barWidthSettings: TimeSeriesChartBarWidthSettings;
noAggregationBarWidthSettings: TimeSeriesChartNoAggregationBarWidthSettings;
visualMapSettings?: TimeSeriesChartVisualMapSettings;
}
export const timeSeriesChartDefaultSettings: TimeSeriesChartSettings = {
@ -675,7 +715,7 @@ export const timeSeriesChartDefaultSettings: TimeSeriesChartSettings = {
};
export interface SeriesFillSettings {
type: SeriesFillType;
type: SeriesFillType | 'default';
opacity: number;
gradient: {
start: number;
@ -960,7 +1000,7 @@ export const createTimeSeriesXAxisOption = (settings: TimeSeriesChartXAxisSettin
fontFamily: xAxisTickLabelStyle.fontFamily,
fontSize: xAxisTickLabelStyle.fontSize,
hideOverlap: true,
formatter: (value: number, index: number, extra: {level: number}) => {
formatter: (value: number, _index: number, extra: {level: number}) => {
const unit = tsToFormatTimeUnit(value);
const format = ticksFormat[unit];
const formatted = datePipe.transform(value, format);
@ -990,6 +1030,21 @@ export const createTimeSeriesXAxisOption = (settings: TimeSeriesChartXAxisSettin
};
};
export const createTimeSeriesVisualMapOption = (settings: TimeSeriesChartVisualMapSettings,
selectedRanges: {[key: number]: boolean}): VisualMapComponentOption => ({
show: false,
type: 'piecewise',
selected: selectedRanges,
dimension: 1,
pieces: settings.pieces,
outOfRange: {
color: settings.outOfRangeColor
},
inRange: !settings.pieces.length ? {
color: settings.outOfRangeColor
} : undefined
});
export const generateChartData = (dataItems: TimeSeriesChartDataItem[],
thresholdItems: TimeSeriesChartThresholdItem[],
stack: boolean,
@ -1072,6 +1127,9 @@ const generateChartThresholds = (thresholdItems: TimeSeriesChartThresholdItem[])
}
}
};
if (item.settings.additionalLabelOption) {
seriesOption.markLine.label = {...seriesOption.markLine.label, ...item.settings.additionalLabelOption};
}
item.option = seriesOption;
}
seriesOption.markLine.data = [];
@ -1147,7 +1205,7 @@ const generateChartSeries = (dataItems: TimeSeriesChartDataItem[],
export const updateDarkMode = (options: EChartsOption, settings: TimeSeriesChartSettings,
yAxisList: TimeSeriesChartYAxis[],
dataItems: TimeSeriesChartDataItem[], thresholdDataItems: TimeSeriesChartThresholdItem[],
dataItems: TimeSeriesChartDataItem[],
darkMode: boolean): EChartsOption => {
options.darkMode = darkMode;
if (Array.isArray(options.yAxis)) {
@ -1246,7 +1304,7 @@ const createTimeSeriesChartSeries = (item: TimeSeriesChartDataItem,
lineSeriesOption.areaStyle = {};
if (lineSettings.fillAreaSettings.type === SeriesFillType.opacity) {
lineSeriesOption.areaStyle.opacity = lineSettings.fillAreaSettings.opacity;
} else {
} else if (lineSettings.fillAreaSettings.type === SeriesFillType.gradient) {
lineSeriesOption.areaStyle.opacity = 1;
lineSeriesOption.areaStyle.color = createLinearOpacityGradient(seriesColor, lineSettings.fillAreaSettings.gradient);
}
@ -1264,7 +1322,7 @@ const createTimeSeriesChartSeries = (item: TimeSeriesChartDataItem,
borderWidth: barSettings.showBorder ? barSettings.borderWidth : 0,
borderRadius: barSettings.borderRadius
};
if (barSettings.backgroundSettings.type === SeriesFillType.none) {
if (barSettings.backgroundSettings.type === SeriesFillType.none || barSettings.backgroundSettings.type === 'default') {
barVisualSettings.color = seriesColor;
} else if (barSettings.backgroundSettings.type === SeriesFillType.opacity) {
barVisualSettings.color = tinycolor(seriesColor).setAlpha(barSettings.backgroundSettings.opacity).toRgbString();

View File

@ -18,6 +18,7 @@ import { WidgetContext } from '@home/models/widget-component.models';
import {
AxisPosition,
calculateThresholdsOffset,
createTimeSeriesVisualMapOption,
createTimeSeriesXAxisOption,
createTimeSeriesYAxis,
defaultTimeSeriesChartYAxisSettings,
@ -51,7 +52,8 @@ import {
EChartsOption,
echartsTooltipFormatter,
EChartsTooltipTrigger,
getAxisExtent, getFocusedSeriesIndex,
getAxisExtent,
getFocusedSeriesIndex,
measureXAxisNameHeight,
measureYAxisNameWidth,
toNamedData
@ -60,7 +62,7 @@ import { DateFormatProcessor } from '@shared/models/widget-settings.models';
import { isDefinedAndNotNull, isEqual, mergeDeep } from '@core/utils';
import { DataKey, Datasource, DatasourceType, widgetType } from '@shared/models/widget.models';
import * as echarts from 'echarts/core';
import { CallbackDataParams } from 'echarts/types/dist/shared';
import { CallbackDataParams, PiecewiseVisualMapOption } from 'echarts/types/dist/shared';
import { Renderer2 } from '@angular/core';
import { CustomSeriesOption, LineSeriesOption } from 'echarts/charts';
import { BehaviorSubject } from 'rxjs';
@ -107,6 +109,9 @@ export class TbTimeSeriesChart {
private dataItems: TimeSeriesChartDataItem[] = [];
private thresholdItems: TimeSeriesChartThresholdItem[] = [];
private hasVisualMap = false;
private visualMapSelectedRanges: {[key: number]: boolean};
private timeSeriesChart: ECharts;
private timeSeriesChartOptions: EChartsOption;
@ -145,6 +150,7 @@ export class TbTimeSeriesChart {
this.setupYAxes();
this.setupData();
this.setupThresholds();
this.setupVisualMap();
if (this.settings.showTooltip && this.settings.tooltipShowDate) {
this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat);
}
@ -186,6 +192,9 @@ export class TbTimeSeriesChart {
} else {
this.timeSeriesChartOptions.tooltip[0].axisPointer.type = 'shadow';
}
if (this.hasVisualMap) {
(this.timeSeriesChartOptions.visualMap as PiecewiseVisualMapOption).selected = this.visualMapSelectedRanges;
}
this.barRenderSharedContext.timeInterval = this.ctx.timeWindow.interval;
this.updateSeriesData(true);
if (this.highlightedDataKey) {
@ -263,6 +272,16 @@ export class TbTimeSeriesChart {
}
}
public toggleVisualMapRange(index: number): void {
if (this.hasVisualMap) {
this.visualMapSelectedRanges[index] = !this.visualMapSelectedRanges[index];
this.timeSeriesChart.dispatchAction({
type: 'selectDataRange',
selected: this.visualMapSelectedRanges
});
}
}
public destroy(): void {
if (this.shapeResize$) {
this.shapeResize$.disconnect();
@ -284,8 +303,7 @@ export class TbTimeSeriesChart {
this.darkMode = darkMode;
if (this.timeSeriesChart) {
this.timeSeriesChartOptions = updateDarkMode(this.timeSeriesChartOptions,
this.settings, this.yAxisList, this.dataItems,
this.thresholdItems, darkMode);
this.settings, this.yAxisList, this.dataItems, darkMode);
this.timeSeriesChart.setOption(this.timeSeriesChartOptions);
}
}
@ -431,6 +449,15 @@ export class TbTimeSeriesChart {
}
}
private setupVisualMap(): void {
if (this.settings.visualMapSettings?.pieces && this.settings.visualMapSettings?.pieces.length) {
this.hasVisualMap = true;
this.visualMapSelectedRanges = {};
this.settings.visualMapSettings.pieces.forEach((_val, index) => {
this.visualMapSelectedRanges[index] = true;
});
}
}
private nextComponentId(): string {
return (this.componentIndexCounter++) + '';
@ -536,6 +563,10 @@ export class TbTimeSeriesChart {
animationEasingUpdate: this.settings.animation.animationEasingUpdate,
animationDelayUpdate: this.settings.animation.animationDelayUpdate
};
if (this.hasVisualMap) {
this.timeSeriesChartOptions.visualMap =
createTimeSeriesVisualMapOption(this.settings.visualMapSettings, this.visualMapSelectedRanges);
}
this.timeSeriesChartOptions.xAxis[0].tbTimeWindow = this.ctx.defaultSubscription.timeWindow;