-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.scss
index f958bb6354..22e52e05dd 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.scss
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.scss
@@ -52,7 +52,18 @@
}
.tb-value-chart-card-chart {
flex: 1;
+ min-width: 0;
height: 100%;
}
+ &.auto-scale {
+ .tb-value-chart-card-value-container {
+ width: 35%;
+ display: flex;
+ justify-content: flex-start;
+ }
+ .tb-value-chart-card-chart {
+ width: 65%;
+ }
+ }
}
}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts
index d4b8404275..b59098de17 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts
@@ -34,7 +34,9 @@ import {
ColorProcessor,
ComponentStyle,
getDataKey,
- overlayStyle, resolveCssSize,
+ overlayStyle,
+ resolveCssSize,
+ simpleDateFormat,
textStyle
} from '@shared/models/widget-settings.models';
import { WidgetComponent } from '@home/components/widget/widget.component';
@@ -44,16 +46,20 @@ import {
ValueChartCardLayout,
ValueChartCardWidgetSettings
} from '@home/components/widget/lib/cards/value-chart-card-widget.models';
-import { TbFlot } from '@home/components/widget/lib/flot-widget';
import { DataKey } from '@shared/models/widget.models';
-import { TbFlotKeySettings, TbFlotSettings } from '@home/components/widget/lib/flot-widget.models';
import { getTsValueByLatestDataKey } from '@home/components/widget/lib/cards/aggregated-value-card.models';
import { Observable } from 'rxjs';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
+import { TbTimeSeriesChart } from '@home/components/widget/lib/chart/time-series-chart';
+import {
+ TimeSeriesChartKeySettings,
+ TimeSeriesChartSeriesType,
+ TimeSeriesChartSettings
+} from '@home/components/widget/lib/chart/time-series-chart.models';
const layoutHeight = 56;
-const valueRelativeMaxWidth = 0.5;
+const valueRelativeWidth = 0.35;
@Component({
selector: 'tb-value-chart-card-widget',
@@ -81,6 +87,7 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
widgetTitlePanel: TemplateRef
;
layout: ValueChartCardLayout;
+ autoScale: boolean;
showValue = true;
valueText = 'N/A';
@@ -90,8 +97,8 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
backgroundStyle$: Observable;
overlayStyle: ComponentStyle = {};
- private flot: TbFlot;
- private flotDataKey: DataKey;
+ private lineChart: TbTimeSeriesChart;
+ private lineChartDataKey: DataKey;
private valueKey: DataKey;
private contentResize$: ResizeObserver;
@@ -129,6 +136,7 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
}
this.layout = this.settings.layout;
+ this.autoScale = this.settings.autoScale;
this.showValue = this.settings.showValue;
this.valueStyle = textStyle(this.settings.valueFont);
@@ -138,37 +146,34 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
this.overlayStyle = overlayStyle(this.settings.background.overlay);
if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) {
- this.flotDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0];
- this.flotDataKey.settings = {
- fillLines: false,
- showLines: true,
- lineWidth: 2
- } as TbFlotKeySettings;
+ this.lineChartDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0];
+ this.lineChartDataKey.settings = {
+ showPointLabel: false,
+ type: TimeSeriesChartSeriesType.line,
+ lineSettings: {
+ smooth: true,
+ showLine: true,
+ lineWidth: 2,
+ showPoints: false
+ }
+ } as TimeSeriesChartKeySettings;
}
}
public ngAfterViewInit() {
- const settings = {
- shadowSize: 0,
- enableSelection: false,
- smoothLines: true,
- grid: {
- tickColor: 'rgba(0,0,0,0.12)',
- horizontalLines: false,
- verticalLines: false,
- outlineWidth: 0,
- minBorderMargin: 0,
- margin: 0
+ const settings: TimeSeriesChartSettings = {
+ dataZoom: false,
+ xAxis: {
+ show: false
},
- yaxis: {
- showLabels: false,
- tickGenerator: 'return [(axis.max + axis.min) / 2];'
+ yAxis: {
+ show: false
},
- xaxis: {
- showLabels: false
- }
- } as TbFlotSettings;
- this.flot = new TbFlot(this.ctx, 'line', $(this.chartElement.nativeElement), settings);
+ tooltipDateInterval: false,
+ tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm:ss')
+ } as TimeSeriesChartSettings;
+
+ this.lineChart = new TbTimeSeriesChart(this.ctx, settings, this.chartElement.nativeElement, this.renderer, false);
this.contentResize$ = new ResizeObserver(() => {
this.onResize();
@@ -190,7 +195,9 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
}
public onDataUpdated() {
- this.flot.update();
+ if (this.lineChart) {
+ this.lineChart.update();
+ }
}
public onLatestDataUpdated() {
@@ -206,20 +213,21 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
this.valueColor.update(value);
this.cd.detectChanges();
setTimeout(() => {
- this.onResize(false);
+ this.onResize();
}, 0);
}
}
public onEditModeChanged() {
- this.flot.checkMouseEvents();
}
public onDestroy() {
- this.flot.destroy();
+ if (this.lineChart) {
+ this.lineChart.destroy();
+ }
}
- private onResize(fitTargetHeight = true) {
+ private onResize(fitTargetWidth = true) {
if (this.settings.autoScale && this.showValue) {
const contentWidth = this.valueChartCardContent.nativeElement.getBoundingClientRect().width;
const contentHeight = this.valueChartCardContent.nativeElement.getBoundingClientRect().height;
@@ -228,26 +236,26 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
this.valueFontSize = resolveCssSize(fontSize)[0];
}
const valueRelativeHeight = Math.min(this.valueFontSize / layoutHeight, 1);
- const targetValueHeight = contentHeight * valueRelativeHeight;
- const maxValueWidth = contentWidth * valueRelativeMaxWidth;
- this.setValueFontSize(targetValueHeight, maxValueWidth, fitTargetHeight);
+ const targetValueWidth = contentWidth * valueRelativeWidth;
+ const maxValueHeight = contentHeight * valueRelativeHeight;
+ this.setValueFontSize(targetValueWidth, maxValueHeight, fitTargetWidth);
}
- this.flot.resize();
+ this.lineChart.resize();
}
- private setValueFontSize(targetHeight: number, maxWidth: number, fitTargetHeight = true) {
+ private setValueFontSize(targetWidth: number, maxHeight: number, fitTargetWidth = true) {
const fontSize = getComputedStyle(this.valueChartCardValue.nativeElement).fontSize;
let valueFontSize = resolveCssSize(fontSize)[0];
this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'fontSize', valueFontSize + 'px');
this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'lineHeight', '1');
- let valueHeight = this.valueChartCardValue.nativeElement.getBoundingClientRect().height;
- while (fitTargetHeight && valueHeight < targetHeight) {
+ let valueWidth = this.valueChartCardValue.nativeElement.getBoundingClientRect().width;
+ while (fitTargetWidth && valueWidth < targetWidth) {
valueFontSize++;
this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'fontSize', valueFontSize + 'px');
- valueHeight = this.valueChartCardValue.nativeElement.getBoundingClientRect().height;
+ valueWidth = this.valueChartCardValue.nativeElement.getBoundingClientRect().width;
}
- let valueWidth = this.valueChartCardValue.nativeElement.getBoundingClientRect().width;
- while ((valueHeight > targetHeight || valueWidth > maxWidth) && valueFontSize > 6) {
+ let valueHeight = this.valueChartCardValue.nativeElement.getBoundingClientRect().height;
+ while ((valueWidth > targetWidth || valueHeight > maxHeight) && valueFontSize > 6) {
valueFontSize--;
this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'fontSize', valueFontSize + 'px');
valueWidth = this.valueChartCardValue.nativeElement.getBoundingClientRect().width;
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts
index 2e2faf35e8..c523bf7927 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts
@@ -53,19 +53,14 @@ import { CallbackDataParams, CustomSeriesRenderItem, LabelLayoutOptionCallback }
import {
ECharts,
echartsModule,
- EChartsOption,
+ EChartsOption, EChartsSeriesItem,
echartsTooltipFormatter,
NamedDataSet,
toNamedData
} from '@home/components/widget/lib/chart/echarts-widget.models';
import { IntervalMath } from '@shared/models/time/time.models';
-interface BarChartDataItem {
- id: string;
- dataKey: DataKey;
- data: NamedDataSet;
- enabled: boolean;
-}
+type BarChartDataItem = EChartsSeriesItem;
interface BarChartLegendItem {
id: string;
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts
index d0b54fed53..99ea088c92 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts
@@ -75,6 +75,7 @@ export const barChartWithLabelsDefaultSettings: BarChartWithLabelsWidgetSettings
},
tooltipValueColor: 'rgba(0, 0, 0, 0.76)',
tooltipShowDate: true,
+ tooltipDateInterval: true,
tooltipDateFormat: customDateFormat('MMMM y'),
tooltipDateFont: {
family: 'Roboto',
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts
index 515e47559d..7564bd9cab 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts
@@ -17,35 +17,45 @@
import * as echarts from 'echarts/core';
import { Axis } from 'echarts';
import AxisModel from 'echarts/types/src/coord/cartesian/AxisModel';
-import { formatValue, isDefinedAndNotNull, isNumber, isString } from '@core/utils';
+import { estimateLabelUnionRect } from 'echarts/lib/coord/axisHelper';
+import { formatValue, isDefinedAndNotNull } from '@core/utils';
import TimeScale from 'echarts/types/src/scale/Time';
import {
- DataZoomComponent, DataZoomComponentOption,
- GridComponent, GridComponentOption,
- MarkLineComponent, MarkLineComponentOption,
- TooltipComponent, TooltipComponentOption,
- VisualMapComponent, VisualMapComponentOption
+ DataZoomComponent,
+ DataZoomComponentOption,
+ GridComponent,
+ GridComponentOption,
+ MarkLineComponent,
+ MarkLineComponentOption,
+ TooltipComponent,
+ TooltipComponentOption,
+ VisualMapComponent,
+ VisualMapComponentOption
} from 'echarts/components';
import {
BarChart,
- LineChart,
+ BarSeriesOption,
CustomChart,
CustomSeriesOption,
+ LineChart,
LineSeriesOption,
- BarSeriesOption, PieSeriesOption, PieChart
+ PieChart,
+ PieSeriesOption
} from 'echarts/charts';
import { LabelLayout } from 'echarts/features';
import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
-import { DataEntry, DataSet } from '@shared/models/widget.models';
+import { DataEntry, DataKey, DataSet } from '@shared/models/widget.models';
import {
calculateAggIntervalWithWidgetTimeWindow,
- Interval,
IntervalMath,
WidgetTimewindow
} from '@shared/models/time/time.models';
import { CallbackDataParams } from 'echarts/types/dist/shared';
import { Renderer2 } from '@angular/core';
import { DateFormatProcessor, DateFormatSettings, Font } from '@shared/models/widget-settings.models';
+import GlobalModel from 'echarts/types/src/model/Global';
+import Axis2D from 'echarts/types/src/coord/cartesian/Axis2D';
+import SeriesModel from 'echarts/types/src/model/Series';
class EChartsModule {
private initialized = false;
@@ -139,6 +149,133 @@ export type EChartsDataItem = [number, any, number, number];
export type NamedDataSet = {name: string; value: EChartsDataItem}[];
+export type EChartsSeriesItem = {
+ id: string;
+ dataKey: DataKey;
+ data: NamedDataSet;
+ enabled: boolean;
+ units?: string;
+ decimals?: number;
+};
+
+export const getXAxis = (chart: ECharts): Axis2D => {
+ const model: GlobalModel = (chart as any).getModel();
+ const models = model.queryComponents({mainType: 'xAxis'});
+ if (models?.length) {
+ const axisModel = models[0] as AxisModel;
+ return axisModel.axis;
+ }
+ return null;
+};
+
+export const getYAxis = (chart: ECharts, axisId: string): Axis2D => {
+ const model: GlobalModel = (chart as any).getModel();
+ const models = model.queryComponents({mainType: 'yAxis', id: axisId});
+ if (models?.length) {
+ const axisModel = models[0] as AxisModel;
+ return axisModel.axis;
+ }
+ return null;
+};
+
+export const calculateYAxisWidth = (chart: ECharts, axisId: string): number => {
+ const axis = getYAxis(chart, axisId);
+ return calculateAxisSize(axis);
+};
+
+export const calculateXAxisHeight = (chart: ECharts): number => {
+ const axis = getXAxis(chart);
+ return calculateAxisSize(axis);
+};
+
+const calculateAxisSize = (axis: Axis2D): number => {
+ let size = 0;
+ if (axis && axis.model.option.show) {
+ const labelUnionRect = estimateLabelUnionRect(axis);
+ if (labelUnionRect) {
+ const margin = axis.model.get(['axisLabel', 'margin']);
+ const dimension = axis.isHorizontal() ? 'height' : 'width';
+ size += labelUnionRect[dimension] + margin;
+ }
+ if (!axis.scale.isBlank() && axis.model.get(['axisTick', 'show'])) {
+ const tickLength = axis.model.get(['axisTick', 'length']);
+ size += tickLength;
+ }
+ }
+ return size;
+};
+
+export const measureYAxisNameWidth = (chart: ECharts, axisId: string, name: string): number => {
+ const axis = getYAxis(chart, axisId);
+ if (axis) {
+ return axis.model.getModel('nameTextStyle').getTextRect(name).height;
+ }
+ return 0;
+};
+
+export const measureXAxisNameHeight = (chart: ECharts, name: string): number => {
+ const axis = getXAxis(chart);
+ if (axis) {
+ return axis.model.getModel('nameTextStyle').getTextRect(name).height;
+ }
+ return 0;
+};
+
+export const measureThresholdLabelOffset = (chart: ECharts, axisId: string, thresholdId: string, value: any): [number, number] => {
+ const axis = getYAxis(chart, axisId);
+ if (axis && !axis.scale.isBlank()) {
+ const extent = axis.scale.getExtent();
+ const model: GlobalModel = (chart as any).getModel();
+ const models = model.queryComponents({mainType: 'series', id: thresholdId});
+ if (models?.length) {
+ const lineSeriesModel = models[0] as SeriesModel;
+ const markLineModel = lineSeriesModel.getModel('markLine');
+ const labelPosition = markLineModel.get(['label', 'position']);
+ if (labelPosition === 'start' || labelPosition === 'end') {
+ const labelModel = markLineModel.getModel('label');
+ const formatter = markLineModel.get(['label', 'formatter']);
+ let textWidth = 0;
+ if (Array.isArray(value)) {
+ for (const val of value) {
+ if (val >= extent[0] && val <= extent[1]) {
+ const textVal = typeof formatter === 'string' ? formatter : formatter({value: val} as CallbackDataParams);
+ textWidth = Math.max(textWidth, labelModel.getTextRect(textVal).width);
+ }
+ }
+ } else {
+ if (value >= extent[0] && value <= extent[1]) {
+ const textVal = typeof formatter === 'string' ? formatter : formatter({value} as CallbackDataParams);
+ textWidth = labelModel.getTextRect(textVal).width;
+ }
+ }
+ if (!textWidth) {
+ return [0,0];
+ }
+ const distanceOpt = markLineModel.get(['label', 'distance']);
+ let distance = 5;
+ if (distanceOpt) {
+ distance = typeof distanceOpt === 'number' ? distanceOpt : distanceOpt[0];
+ }
+ const offset = distance + textWidth;
+ if (labelPosition === 'start') {
+ return [offset, 0];
+ } else {
+ return [0, offset];
+ }
+ }
+ }
+ }
+ return [0,0];
+};
+
+export const getAxisExtent = (chart: ECharts, axisId: string): [number, number] => {
+ const axis = getYAxis(chart, axisId);
+ if (axis) {
+ return axis.scale.getExtent();
+ }
+ return [0,0];
+};
+
export const toNamedData = (data: DataSet): NamedDataSet => {
if (!data?.length) {
return [];
@@ -162,11 +299,18 @@ const toEChartsDataItem = (entry: DataEntry): EChartsDataItem => {
return item;
};
+export enum EChartsTooltipTrigger {
+ point = 'point',
+ axis = 'axis'
+}
+
export interface EChartsTooltipWidgetSettings {
showTooltip: boolean;
+ tooltipTrigger?: EChartsTooltipTrigger;
tooltipValueFont: Font;
tooltipValueColor: string;
tooltipShowDate: boolean;
+ tooltipDateInterval?: boolean;
tooltipDateFormat: DateFormatSettings;
tooltipDateFont: Font;
tooltipDateColor: string;
@@ -177,13 +321,15 @@ export interface EChartsTooltipWidgetSettings {
export const echartsTooltipFormatter = (renderer: Renderer2,
tooltipDateFormat: DateFormatProcessor,
settings: EChartsTooltipWidgetSettings,
- params: CallbackDataParams[],
+ params: CallbackDataParams[] | CallbackDataParams,
decimals: number,
units: string,
- focusedSeriesIndex: number): null | HTMLElement => {
- if (!params.length || !params[0]) {
+ focusedSeriesIndex: number,
+ series?: EChartsSeriesItem[]): null | HTMLElement => {
+ if (!params || Array.isArray(params) && !params[0]) {
return null;
}
+ const firstParam = Array.isArray(params) ? params[0] : params;
const tooltipElement: HTMLElement = renderer.createElement('div');
renderer.setStyle(tooltipElement, 'display', 'flex');
renderer.setStyle(tooltipElement, 'flex-direction', 'column');
@@ -192,9 +338,9 @@ export const echartsTooltipFormatter = (renderer: Renderer2,
if (settings.tooltipShowDate) {
const dateElement: HTMLElement = renderer.createElement('div');
let dateText: string;
- const startTs = params[0].value[2];
- const endTs = params[0].value[3];
- if (startTs && endTs && (endTs - 1) > startTs) {
+ const startTs = firstParam.value[2];
+ const endTs = firstParam.value[3];
+ if (settings.tooltipDateInterval && startTs && endTs && (endTs - 1) > startTs) {
const startDateText = tooltipDateFormat.update(startTs);
const endDateText = tooltipDateFormat.update(endTs - 1);
if (startDateText === endDateText) {
@@ -203,7 +349,7 @@ export const echartsTooltipFormatter = (renderer: Renderer2,
dateText = startDateText + ' - ' + endDateText;
}
} else {
- const ts = params[0].value[0];
+ const ts = firstParam.value[0];
dateText = tooltipDateFormat.update(ts);
}
renderer.appendChild(dateElement, renderer.createText(dateText));
@@ -216,14 +362,16 @@ export const echartsTooltipFormatter = (renderer: Renderer2,
renderer.appendChild(tooltipElement, dateElement);
}
let seriesParams: CallbackDataParams = null;
- if (focusedSeriesIndex > -1) {
+ if (Array.isArray(params) && focusedSeriesIndex > -1) {
seriesParams = params.find(param => param.seriesIndex === focusedSeriesIndex);
+ } else if (!Array.isArray(params)) {
+ seriesParams = params;
}
if (seriesParams) {
- renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units));
- } else {
+ renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units, series));
+ } else if (Array.isArray(params)) {
for (seriesParams of params) {
- renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units));
+ renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units, series));
}
}
return tooltipElement;
@@ -233,7 +381,8 @@ const constructEchartsTooltipSeriesElement = (renderer: Renderer2,
settings: EChartsTooltipWidgetSettings,
seriesParams: CallbackDataParams,
decimals: number,
- units: string): HTMLElement => {
+ units: string,
+ series?: EChartsSeriesItem[]): HTMLElement => {
const labelValueElement: HTMLElement = renderer.createElement('div');
renderer.setStyle(labelValueElement, 'display', 'flex');
renderer.setStyle(labelValueElement, 'flex-direction', 'row');
@@ -262,7 +411,16 @@ const constructEchartsTooltipSeriesElement = (renderer: Renderer2,
renderer.setStyle(labelTextElement, 'color', 'rgba(0, 0, 0, 0.76)');
renderer.appendChild(labelElement, labelTextElement);
const valueElement: HTMLElement = renderer.createElement('div');
- const value = formatValue(seriesParams.value[1], decimals, units, false);
+ let formatDecimals = decimals;
+ let formatUnits = units;
+ if (series) {
+ const item = series.find(s => s.id === seriesParams.seriesId);
+ if (item) {
+ formatDecimals = item.decimals;
+ formatUnits = item.units;
+ }
+ }
+ const value = formatValue(seriesParams.value[1], formatDecimals, formatUnits, false);
renderer.appendChild(valueElement, renderer.createText(value));
renderer.setStyle(valueElement, 'flex', '1');
renderer.setStyle(valueElement, 'text-align', 'end');
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts
index c50813f7b6..31905e70ba 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts
@@ -71,6 +71,7 @@ export const rangeChartDefaultSettings: RangeChartWidgetSettings = {
},
tooltipValueColor: 'rgba(0, 0, 0, 0.76)',
tooltipShowDate: true,
+ tooltipDateInterval: true,
tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm'),
tooltipDateFont: {
family: 'Roboto',
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-bar.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-bar.models.ts
new file mode 100644
index 0000000000..37ce7a9c7f
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-bar.models.ts
@@ -0,0 +1,134 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { LinearGradientObject } from 'zrender/lib/graphic/LinearGradient';
+import { Interval, IntervalMath } from '@shared/models/time/time.models';
+import { LabelFormatterCallback, SeriesLabelOption } from 'echarts/types/src/util/types';
+import { TimeSeriesChartDataItem } from '@home/components/widget/lib/chart/time-series-chart.models';
+import { CustomSeriesRenderItemParams } from 'echarts';
+import { CustomSeriesRenderItemAPI, CustomSeriesRenderItemReturn } from 'echarts/types/dist/shared';
+import { isNumeric } from '@core/utils';
+import * as echarts from 'echarts/core';
+
+export interface BarVisualSettings {
+ color: string | LinearGradientObject;
+ borderColor: string;
+ borderWidth: number;
+ borderRadius: number;
+}
+
+export interface BarRenderContext {
+ barsCount?: number;
+ barIndex?: number;
+ timeInterval?: Interval;
+ visualSettings?: BarVisualSettings;
+ labelOption?: SeriesLabelOption;
+ barStackIndex?: number;
+ currentStackItems?: TimeSeriesChartDataItem[];
+}
+
+export const renderTimeSeriesBar = (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI,
+ renderCtx: BarRenderContext): CustomSeriesRenderItemReturn => {
+ const time = api.value(0) as number;
+ let start = api.value(2) as number;
+ const end = api.value(3) as number;
+ let interval = end - start;
+ const ts = start ? start : time;
+ if (!start || !end || !interval) {
+ interval = IntervalMath.numberValue(renderCtx.timeInterval);
+ start = time - interval / 2;
+ }
+ const gap = 0.3;
+ const barInterval = interval / (renderCtx.barsCount + gap * (renderCtx.barsCount + 3));
+ const intervalGap = barInterval * gap * 2;
+ const barGap = barInterval * gap;
+ const value = api.value(1);
+ const startTime = start + intervalGap + (barInterval + barGap) * renderCtx.barIndex;
+ const delta = barInterval;
+ let offset = 0;
+ if (renderCtx.currentStackItems?.length) {
+ for (let i = 0; i < renderCtx.barStackIndex; i++) {
+ const stackItem = renderCtx.currentStackItems[i];
+ const dataName = ts + '';
+ const data = stackItem.data.find(d => d.name === dataName);
+ if (data) {
+ const val = data.value[1];
+ if (isNumeric(val)) {
+ offset += Number(val);
+ }
+ }
+ }
+ }
+ let lowerLeft: number[];
+ if (offset !== 0 && isNumeric(value)) {
+ lowerLeft = api.coord([startTime, value >= 0 ? Number(value) + offset : offset]);
+ } else {
+ lowerLeft = api.coord([startTime, value >= 0 ? value : 0]);
+ }
+ const size = api.size([delta, value]);
+ const width = size[0];
+ const height = size[1];
+
+ const coordSys: {x: number; y: number; width: number; height: number} = params.coordSys as any;
+
+ const rectShape = echarts.graphic.clipRectByRect({
+ x: lowerLeft[0],
+ y: lowerLeft[1],
+ width,
+ height
+ }, {
+ x: coordSys.x,
+ y: coordSys.y,
+ width: coordSys.width,
+ height: coordSys.height
+ });
+
+ const zeroPos = api.coord([0, offset]);
+
+ const style: any = {
+ fill: renderCtx.visualSettings.color,
+ stroke: renderCtx.visualSettings.borderColor,
+ lineWidth: renderCtx.visualSettings.borderWidth
+ };
+
+ if (renderCtx.labelOption.show) {
+ let position = renderCtx.labelOption.position;
+ if (value < 0) {
+ if (position === 'top') {
+ position = 'bottom';
+ } else if (position === 'bottom') {
+ position = 'top';
+ }
+ }
+ style.text = (renderCtx.labelOption.formatter as LabelFormatterCallback)({value: [null, value]} as any);
+ style.textDistance = 5;
+ style.textPosition = position;
+ style.rich = renderCtx.labelOption.rich;
+ }
+
+ return rectShape && {
+ type: 'rect',
+ id: time + '',
+ shape: {...rectShape, r: renderCtx.visualSettings.borderRadius},
+ style,
+ focus: 'series',
+ transition: 'all',
+ enterFrom: {
+ style: { opacity: 0 },
+ shape: { height: 0, y: zeroPos[1] }
+ }
+ };
+};
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html
new file mode 100644
index 0000000000..1b96aac669
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {{ 'legend.Min' | translate }} |
+ {{ 'legend.Max' | translate }} |
+ {{ 'legend.Avg' | translate }} |
+ {{ 'legend.Total' | translate }} |
+ {{ 'legend.Latest' | translate }} |
+
+
+
+
+ |
+
+ |
+
+ {{ legendData.data[legendKey.dataIndex].min }}
+ |
+
+ {{ legendData.data[legendKey.dataIndex].max }}
+ |
+
+ {{ legendData.data[legendKey.dataIndex].avg }}
+ |
+
+ {{ legendData.data[legendKey.dataIndex].total }}
+ |
+
+ {{ legendData.data[legendKey.dataIndex].latest }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ legendKey.dataKey.label }}
+
+
+
+
+
+ | {{ label | translate }} |
+
+ {{ legendData.data[legendKey.dataIndex][type] }}
+ |
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.scss
new file mode 100644
index 0000000000..9d990c50a7
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.scss
@@ -0,0 +1,181 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+$maxLegendWidth: 25%;
+$maxLegendHeight: 35%;
+
+.tb-time-series-chart-panel {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 20px 24px 24px 24px;
+ > div:not(.tb-time-series-chart-overlay) {
+ z-index: 1;
+ }
+ .tb-time-series-chart-overlay {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ bottom: 12px;
+ right: 12px;
+ }
+ div.tb-widget-title {
+ padding: 0;
+ }
+ .tb-time-series-chart-content {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ &.legend-top {
+ flex-direction: column-reverse;
+ }
+ &.legend-right {
+ flex-direction: row;
+ }
+ &.legend-left {
+ flex-direction: row-reverse;
+ }
+ .tb-time-series-chart-shape {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ &.legend-right, &.legend-left {
+ .tb-time-series-chart-legend {
+ display: inline-grid;
+ grid-auto-flow: column;
+ grid-template-rows: repeat(auto-fit, minmax(16px, min-content));
+ max-width: calc($maxLegendWidth - 8px);
+ height: fit-content;
+ max-height: 100%;
+ }
+ }
+ &.legend-top, &.legend-bottom {
+ .tb-time-series-chart-legend {
+ align-self: center;
+ &.tb-simple-legend {
+ justify-content: center;
+ }
+ &:not(.tb-simple-legend) {
+ width: 100%;
+ }
+ }
+ }
+ .tb-time-series-chart-legend {
+ display: flex;
+ align-items: flex-start;
+ align-self: stretch;
+ column-gap: 16px;
+ row-gap: 8px;
+ flex-wrap: wrap;
+ overflow: auto;
+ width: fit-content;
+ max-width: 100%;
+ max-height: calc($maxLegendHeight - 8px);
+ .tb-time-series-chart-legend-table {
+ border-spacing: 0;
+ table-layout: fixed;
+ &.vertical {
+ width: 100%;
+ table-layout: auto;
+ }
+
+ thead th, tbody th {
+ position: sticky;
+ z-index: 1;
+ backdrop-filter: blur(5000px);
+ }
+ thead th {
+ top: 0;
+ &:first-child {
+ left: 0;
+ z-index: 2;
+ }
+ }
+ tbody th {
+ left: 0;
+ }
+ th, td {
+ &:not(:last-child) {
+ padding-right: 8px;
+ }
+ }
+ thead tr, tbody tr:not(:last-child) {
+ th, td {
+ padding-bottom: 8px;
+ }
+ }
+ .tb-time-series-chart-legend-item {
+ align-items: flex-end;
+ &.left {
+ align-items: flex-start;
+ }
+ }
+ }
+ .tb-time-series-chart-legend-item {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ user-select: none;
+ .tb-time-series-chart-legend-item-label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: #ccc;
+ white-space: nowrap;
+ cursor: pointer;
+ .tb-time-series-chart-legend-item-label-circle {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background-color: #ccc;
+ }
+ }
+ }
+ .tb-time-series-chart-legend-type-label {
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 16px;
+ color: rgba(0, 0, 0, 0.38);
+ white-space: nowrap;
+ text-align: left;
+ &.right {
+ text-align: right;
+ }
+ }
+ .tb-time-series-chart-legend-value {
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 500;
+ line-height: 16px;
+ color: rgba(0, 0, 0, 0.87);
+ white-space: nowrap;
+ text-align: right;
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts
new file mode 100644
index 0000000000..560309b08b
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts
@@ -0,0 +1,163 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import {
+ AfterViewInit,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ Input,
+ OnDestroy,
+ OnInit,
+ Renderer2,
+ TemplateRef,
+ ViewChild,
+ ViewEncapsulation
+} from '@angular/core';
+import { timeSeriesChartKeyDefaultSettings, TimeSeriesChartKeySettings } from '@home/components/widget/lib/chart/time-series-chart.models';
+import { WidgetContext } from '@home/models/widget-component.models';
+import { Observable } from 'rxjs';
+import { backgroundStyle, ComponentStyle, overlayStyle, textStyle } from '@shared/models/widget-settings.models';
+import { ImagePipe } from '@shared/pipe/image.pipe';
+import { DomSanitizer } from '@angular/platform-browser';
+import { LegendConfig, LegendData, LegendKey, LegendPosition } from '@shared/models/widget.models';
+import { TbTimeSeriesChart } from '@home/components/widget/lib/chart/time-series-chart';
+import {
+ timeSeriesChartWidgetDefaultSettings,
+ TimeSeriesChartWidgetSettings
+} from '@home/components/widget/lib/chart/time-series-chart-widget.models';
+import { mergeDeep } from '@core/utils';
+
+@Component({
+ selector: 'tb-time-series-chart-widget',
+ templateUrl: './time-series-chart-widget.component.html',
+ styleUrls: ['./time-series-chart-widget.component.scss'],
+ encapsulation: ViewEncapsulation.None
+})
+export class TimeSeriesChartWidgetComponent implements OnInit, OnDestroy, AfterViewInit {
+
+ @ViewChild('chartShape', {static: false})
+ chartShape: ElementRef;
+
+ settings: TimeSeriesChartWidgetSettings;
+
+ @Input()
+ ctx: WidgetContext;
+
+ @Input()
+ widgetTitlePanel: TemplateRef;
+
+ horizontalLegendPosition = false;
+
+ showLegend: boolean;
+ legendClass: string;
+ legendConfig: LegendConfig;
+ legendData: LegendData;
+ legendKeys: LegendKey[];
+
+ backgroundStyle$: Observable;
+ overlayStyle: ComponentStyle = {};
+
+ legendLabelStyle: ComponentStyle;
+ disabledLegendLabelStyle: ComponentStyle;
+
+ displayLegendValues = false;
+
+ private timeSeriesChart: TbTimeSeriesChart;
+
+ constructor(private imagePipe: ImagePipe,
+ private sanitizer: DomSanitizer,
+ private renderer: Renderer2,
+ private cd: ChangeDetectorRef) {
+ }
+
+ ngOnInit(): void {
+ this.ctx.$scope.timeSeriesChartWidget = this;
+ this.settings = {...timeSeriesChartWidgetDefaultSettings, ...this.ctx.settings};
+
+ this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
+ this.overlayStyle = overlayStyle(this.settings.background.overlay);
+
+ this.showLegend = this.settings.showLegend;
+ if (this.showLegend) {
+ this.legendData = this.ctx.defaultSubscription.legendData;
+ this.legendConfig = this.settings.legendConfig;
+ this.legendKeys = this.legendData.keys;
+ if (this.legendConfig.sortDataKeys) {
+ this.legendKeys = this.legendData.keys.sort((key1, key2) => key1.dataKey.label.localeCompare(key2.dataKey.label));
+ }
+ this.legendKeys.forEach(legendKey => {
+ legendKey.dataKey.settings = mergeDeep({} as TimeSeriesChartKeySettings,
+ timeSeriesChartKeyDefaultSettings, legendKey.dataKey.settings);
+ legendKey.dataKey.hidden = legendKey.dataKey.settings.dataHiddenByDefault;
+ });
+ this.legendKeys = this.legendKeys.filter(legendKey => legendKey.dataKey.settings.showInLegend);
+ if (!this.legendKeys.length) {
+ this.showLegend = false;
+ }
+ }
+
+ if (this.showLegend) {
+ this.horizontalLegendPosition = [LegendPosition.left, LegendPosition.right].includes(this.legendConfig.position);
+ this.legendClass = `legend-${this.legendConfig.position}`;
+ this.legendLabelStyle = textStyle(this.settings.legendLabelFont);
+ this.disabledLegendLabelStyle = textStyle(this.settings.legendLabelFont);
+ this.legendLabelStyle.color = this.settings.legendLabelColor;
+ this.displayLegendValues = this.legendConfig.showMin || this.legendConfig.showMax ||
+ this.legendConfig.showAvg || this.legendConfig.showTotal || this.legendConfig.showLatest;
+ }
+ }
+
+ ngAfterViewInit() {
+ this.timeSeriesChart = new TbTimeSeriesChart(this.ctx, this.settings, this.chartShape.nativeElement, this.renderer);
+ }
+
+ ngOnDestroy() {
+ if (this.timeSeriesChart) {
+ this.timeSeriesChart.destroy();
+ }
+ }
+
+ public onInit() {
+ const borderRadius = this.ctx.$widgetElement.css('borderRadius');
+ this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
+ this.cd.detectChanges();
+ }
+
+ public onDataUpdated() {
+ if (this.timeSeriesChart) {
+ this.timeSeriesChart.update();
+ }
+ }
+
+ public onLatestDataUpdated() {
+ if (this.timeSeriesChart) {
+ this.timeSeriesChart.latestUpdated();
+ }
+ }
+
+ public onLegendKeyEnter(legendKey: LegendKey) {
+ this.timeSeriesChart.keyEnter(legendKey.dataKey);
+ }
+
+ public onLegendKeyLeave(legendKey: LegendKey) {
+ this.timeSeriesChart.keyLeave(legendKey.dataKey);
+ }
+
+ public toggleLegendKey(legendKey: LegendKey) {
+ this.timeSeriesChart.toggleKey(legendKey.dataKey);
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.models.ts
new file mode 100644
index 0000000000..ccfca7c4d0
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.models.ts
@@ -0,0 +1,52 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { timeSeriesChartDefaultSettings, TimeSeriesChartSettings } from '@home/components/widget/lib/chart/time-series-chart.models';
+import { BackgroundSettings, BackgroundType, Font } from '@shared/models/widget-settings.models';
+import { defaultLegendConfig, LegendConfig, LegendPosition, widgetType } from '@shared/models/widget.models';
+import { mergeDeep } from '@core/utils';
+
+export interface TimeSeriesChartWidgetSettings extends TimeSeriesChartSettings {
+ showLegend: boolean;
+ legendLabelFont: Font;
+ legendLabelColor: string;
+ legendConfig: LegendConfig;
+ background: BackgroundSettings;
+}
+
+export const timeSeriesChartWidgetDefaultSettings: TimeSeriesChartWidgetSettings =
+ mergeDeep({} as TimeSeriesChartWidgetSettings, timeSeriesChartDefaultSettings as TimeSeriesChartWidgetSettings, {
+ showLegend: true,
+ legendLabelFont: {
+ family: 'Roboto',
+ size: 12,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '16px'
+ },
+ legendLabelColor: 'rgba(0, 0, 0, 0.76)',
+ legendConfig: {...defaultLegendConfig(widgetType.timeseries), position: LegendPosition.top},
+ background: {
+ type: BackgroundType.color,
+ color: '#fff',
+ overlay: {
+ enabled: false,
+ color: 'rgba(255,255,255,0.72)',
+ blur: 3
+ }
+ }
+ } as TimeSeriesChartWidgetSettings);
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts
new file mode 100644
index 0000000000..ef8b432e46
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts
@@ -0,0 +1,873 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import {
+ ECharts,
+ EChartsOption,
+ EChartsSeriesItem,
+ EChartsTooltipTrigger,
+ EChartsTooltipWidgetSettings,
+ measureThresholdLabelOffset
+} from '@home/components/widget/lib/chart/echarts-widget.models';
+import { ComponentStyle, Font, simpleDateFormat, textStyle } from '@shared/models/widget-settings.models';
+import { XAXisOption, YAXisOption } from 'echarts/types/dist/shared';
+import { CustomSeriesOption, LineSeriesOption } from 'echarts/charts';
+import { formatValue, isDefinedAndNotNull, parseFunction } from '@core/utils';
+import { LinearGradientObject } from 'zrender/lib/graphic/LinearGradient';
+import tinycolor from 'tinycolor2';
+import Axis2D from 'echarts/types/src/coord/cartesian/Axis2D';
+import { Interval } from '@shared/models/time/time.models';
+import { ValueAxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes';
+import { SeriesLabelOption } from 'echarts/types/src/util/types';
+import {
+ BarRenderContext,
+ BarVisualSettings,
+ renderTimeSeriesBar
+} from '@home/components/widget/lib/chart/time-series-chart-bar.models';
+import { DataKey } from '@shared/models/widget.models';
+import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
+import { TbColorScheme } from '@shared/models/color.models';
+
+export enum PointLabelPosition {
+ top = 'top',
+ bottom = 'bottom'
+}
+
+export enum AxisPosition {
+ left = 'left',
+ right = 'right',
+ top = 'top',
+ bottom = 'bottom'
+}
+
+export enum TimeSeriesChartShape {
+ emptyCircle = 'emptyCircle',
+ circle = 'circle',
+ rect = 'rect',
+ roundRect = 'roundRect',
+ triangle = 'triangle',
+ diamond = 'diamond',
+ pin = 'pin',
+ arrow = 'arrow',
+ none = 'none'
+}
+
+export enum TimeSeriesChartLineType {
+ solid = 'solid',
+ dashed = 'dashed',
+ dotted = 'dotted'
+}
+
+export enum ThresholdLabelPosition {
+ start = 'start',
+ middle = 'middle',
+ end = 'end',
+ insideStart = 'insideStart',
+ insideStartTop = 'insideStartTop',
+ insideStartBottom = 'insideStartBottom',
+ insideMiddle = 'insideMiddle',
+ insideMiddleTop = 'insideMiddleTop',
+ insideMiddleBottom = 'insideMiddleBottom',
+ insideEnd = 'insideEnd',
+ insideEndTop = 'insideEndTop',
+ insideEndBottom = 'insideEndBottom'
+}
+
+export interface TimeSeriesChartAxisSettings {
+ show: boolean;
+ position: AxisPosition;
+ label?: string;
+ labelFont?: Font;
+ labelColor?: string;
+ showLine: boolean;
+ lineColor: string;
+ showTicks: boolean;
+ ticksColor: string;
+ showTickLabels: boolean;
+ tickLabelFont: Font;
+ tickLabelColor: string;
+ showSplitLines: boolean;
+ splitLinesColor: string;
+}
+
+export interface TimeSeriesChartYAxisSettings extends TimeSeriesChartAxisSettings {
+ min?: number | string;
+ max?: number | string;
+ intervalCalculator?: string;
+}
+
+export enum TimeSeriesChartThresholdType {
+ constant = 'constant',
+ latestKey = 'latestKey',
+ entity = 'entity'
+}
+
+export interface TimeSeriesChartThreshold {
+ type: TimeSeriesChartThresholdType;
+ value?: number;
+ latestKeyName?: string;
+ entityAlias?: string;
+ entityKeyType?: DataKeyType.attribute | DataKeyType.timeseries;
+ entityKey?: string;
+ units?: string;
+ decimals?: number;
+ lineWidth: number;
+ lineType: TimeSeriesChartLineType;
+ lineColor: string;
+ startSymbol: TimeSeriesChartShape;
+ startSymbolSize: number;
+ endSymbol: TimeSeriesChartShape;
+ endSymbolSize: number;
+ showLabel: boolean;
+ labelPosition: ThresholdLabelPosition;
+ labelFont: Font;
+ labelColor: string;
+}
+
+const timeSeriesChartColorScheme: TbColorScheme = {
+ 'threshold.line': {
+ light: 'rgba(0, 0, 0, 0.76)',
+ dark: '#eee'
+ },
+ 'threshold.label': {
+ light: 'rgba(0, 0, 0, 0.76)',
+ dark: '#eee'
+ },
+ 'axis.line': {
+ light: 'rgba(0, 0, 0, 0.54)',
+ dark: '#B9B8CE'
+ },
+ 'axis.label': {
+ light: 'rgba(0, 0, 0, 0.54)',
+ dark: '#B9B8CE'
+ },
+ 'axis.ticks': {
+ light: 'rgba(0, 0, 0, 0.54)',
+ dark: '#B9B8CE'
+ },
+ 'axis.tickLabel': {
+ light: 'rgba(0, 0, 0, 0.54)',
+ dark: '#B9B8CE'
+ },
+ 'axis.splitLine': {
+ light: 'rgba(0, 0, 0, 0.12)',
+ dark: '#484753'
+ },
+ 'series.pointLabel': {
+ light: 'rgba(0, 0, 0, 0.76)',
+ dark: '#eee'
+ }
+};
+
+export const timeSeriesChartThresholdDefaultSettings: TimeSeriesChartThreshold = {
+ type: TimeSeriesChartThresholdType.constant,
+ units: '',
+ decimals: 0,
+ lineWidth: 1,
+ lineType: TimeSeriesChartLineType.solid,
+ lineColor: timeSeriesChartColorScheme['threshold.line'].light,
+ startSymbol: TimeSeriesChartShape.none,
+ startSymbolSize: 5,
+ endSymbol: TimeSeriesChartShape.arrow,
+ endSymbolSize: 5,
+ showLabel: true,
+ labelPosition: ThresholdLabelPosition.end,
+ labelFont: {
+ family: 'Roboto',
+ size: 12,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '1'
+ },
+ labelColor: timeSeriesChartColorScheme['threshold.label'].light
+};
+
+export interface TimeSeriesChartSettings extends EChartsTooltipWidgetSettings {
+ darkMode: boolean;
+ dataZoom: boolean;
+ stack: boolean;
+ thresholds: TimeSeriesChartThreshold[];
+ xAxis: TimeSeriesChartAxisSettings;
+ yAxis: TimeSeriesChartYAxisSettings;
+}
+
+export const timeSeriesChartDefaultSettings: TimeSeriesChartSettings = {
+ darkMode: false,
+ dataZoom: true,
+ stack: false,
+ thresholds: [],
+ xAxis: {
+ show: true,
+ position: AxisPosition.bottom,
+ label: '',
+ labelFont: {
+ family: 'Roboto',
+ size: 12,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '600',
+ lineHeight: '1'
+ },
+ labelColor: timeSeriesChartColorScheme['axis.label'].light,
+ showLine: true,
+ lineColor: timeSeriesChartColorScheme['axis.line'].light,
+ showTicks: true,
+ ticksColor: timeSeriesChartColorScheme['axis.ticks'].light,
+ showTickLabels: true,
+ tickLabelFont: {
+ family: 'Roboto',
+ size: 10,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '1'
+ },
+ tickLabelColor: timeSeriesChartColorScheme['axis.tickLabel'].light,
+ showSplitLines: true,
+ splitLinesColor: timeSeriesChartColorScheme['axis.splitLine'].light
+ },
+ yAxis: {
+ show: true,
+ position: AxisPosition.left,
+ label: '',
+ labelFont: {
+ family: 'Roboto',
+ size: 12,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '600',
+ lineHeight: '1'
+ },
+ labelColor: timeSeriesChartColorScheme['axis.label'].light,
+ showLine: true,
+ lineColor: timeSeriesChartColorScheme['axis.line'].light,
+ showTicks: true,
+ ticksColor: timeSeriesChartColorScheme['axis.ticks'].light,
+ showTickLabels: true,
+ tickLabelFont: {
+ family: 'Roboto',
+ size: 12,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '1'
+ },
+ tickLabelColor: timeSeriesChartColorScheme['axis.tickLabel'].light,
+ showSplitLines: true,
+ splitLinesColor: timeSeriesChartColorScheme['axis.splitLine'].light
+ },
+ showTooltip: true,
+ tooltipTrigger: EChartsTooltipTrigger.axis,
+ tooltipValueFont: {
+ family: 'Roboto',
+ size: 12,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '500',
+ lineHeight: '16px'
+ },
+ tooltipValueColor: 'rgba(0, 0, 0, 0.76)',
+ tooltipShowDate: true,
+ tooltipDateInterval: true,
+ tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm:ss'),
+ tooltipDateFont: {
+ family: 'Roboto',
+ size: 11,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '16px'
+ },
+ tooltipDateColor: 'rgba(0, 0, 0, 0.76)',
+ tooltipBackgroundColor: 'rgba(255, 255, 255, 0.76)',
+ tooltipBackgroundBlur: 4
+};
+
+export enum SeriesFillType {
+ none = 'none',
+ opacity = 'opacity',
+ gradient = 'gradient'
+}
+
+export interface SeriesFillSettings {
+ type: SeriesFillType;
+ opacity: number;
+ gradient: {
+ start: number;
+ end: number;
+ };
+}
+
+export interface LineSeriesSettings {
+ step: false | 'start' | 'end' | 'middle';
+ smooth: boolean;
+ showLine: boolean;
+ lineWidth: number;
+ lineType: TimeSeriesChartLineType;
+ fillAreaSettings: SeriesFillSettings;
+ showPoints: boolean;
+ pointShape: TimeSeriesChartShape;
+ pointSize: number;
+}
+
+export interface BarSeriesSettings {
+ showBorder: boolean;
+ borderWidth: number;
+ borderRadius: number;
+ backgroundSettings: SeriesFillSettings;
+}
+
+export enum TimeSeriesChartSeriesType {
+ line = 'line',
+ bar = 'bar'
+}
+
+export interface TimeSeriesChartKeySettings {
+ dataHiddenByDefault: boolean;
+ showInLegend: boolean;
+ showPointLabel: boolean;
+ pointLabelPosition: PointLabelPosition;
+ pointLabelFont: Font;
+ pointLabelColor: string;
+ type: TimeSeriesChartSeriesType;
+ lineSettings: LineSeriesSettings;
+ barSettings: BarSeriesSettings;
+}
+
+export const timeSeriesChartKeyDefaultSettings: TimeSeriesChartKeySettings = {
+ showInLegend: true,
+ dataHiddenByDefault: false,
+ showPointLabel: false,
+ pointLabelPosition: PointLabelPosition.top,
+ pointLabelFont: {
+ family: 'Roboto',
+ size: 11,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '1'
+ },
+ pointLabelColor: timeSeriesChartColorScheme['series.pointLabel'].light,
+ type: TimeSeriesChartSeriesType.line,
+ lineSettings: {
+ step: false,
+ smooth: false,
+ showLine: true,
+ lineWidth: 2,
+ lineType: TimeSeriesChartLineType.solid,
+ fillAreaSettings: {
+ type: SeriesFillType.none,
+ opacity: 0.4,
+ gradient: {
+ start: 100,
+ end: 0
+ }
+ },
+ showPoints: false,
+ pointShape: TimeSeriesChartShape.emptyCircle,
+ pointSize: 4
+ },
+ barSettings: {
+ showBorder: false,
+ borderWidth: 2,
+ borderRadius: 0,
+ backgroundSettings: {
+ type: SeriesFillType.none,
+ opacity: 0.4,
+ gradient: {
+ start: 100,
+ end: 0
+ }
+ }
+ }
+};
+
+export interface TimeSeriesChartDataItem extends EChartsSeriesItem {
+ yAxisIndex: number;
+ option?: LineSeriesOption | CustomSeriesOption;
+ barRenderContext?: BarRenderContext;
+}
+
+type TimeSeriesChartThresholdValue = number | string | (number | string)[];
+
+export interface TimeSeriesChartThresholdItem {
+ id: string;
+ yAxisIndex: number;
+ latestDataKey?: DataKey;
+ units?: string;
+ decimals?: number;
+ value: TimeSeriesChartThresholdValue;
+ settings: TimeSeriesChartThreshold;
+ option?: LineSeriesOption;
+}
+
+export interface TimeSeriesChartYAxis {
+ id: string;
+ units: string;
+ option: YAXisOption & ValueAxisBaseOption;
+ intervalCalculator?: (axis: Axis2D) => number;
+}
+
+export const createTimeSeriesYAxis = (axisId: string, units: string,
+ decimals: number, settings: TimeSeriesChartYAxisSettings,
+ darkMode: boolean): TimeSeriesChartYAxis => {
+ const yAxisTickLabelStyle = createChartTextStyle(settings.tickLabelFont,
+ settings.tickLabelColor, darkMode, 'axis.tickLabel');
+ const yAxisNameStyle = createChartTextStyle(settings.labelFont,
+ settings.labelColor, darkMode, 'axis.label');
+ const yAxis: TimeSeriesChartYAxis = {
+ id: axisId,
+ units,
+ option: {
+ show: settings.show,
+ type: 'value',
+ position: settings.position,
+ id: axisId,
+ offset: 0,
+ alignTicks: true,
+ scale: true,
+ min: settings.min,
+ max: settings.max,
+ name: settings.label,
+ nameLocation: 'middle',
+ nameRotate: settings.position === AxisPosition.left ? 90 : -90,
+ nameTextStyle: {
+ color: yAxisNameStyle.color,
+ fontStyle: yAxisNameStyle.fontStyle,
+ fontWeight: yAxisNameStyle.fontWeight,
+ fontFamily: yAxisNameStyle.fontFamily,
+ fontSize: yAxisNameStyle.fontSize
+ },
+ axisLine: {
+ show: settings.showLine,
+ onZero: false,
+ lineStyle: {
+ color: prepareChartThemeColor(settings.lineColor, darkMode, 'axis.line')
+ }
+ },
+ axisTick: {
+ show: settings.showTicks,
+ lineStyle: {
+ color: prepareChartThemeColor(settings.ticksColor, darkMode, 'axis.ticks')
+ }
+ },
+ axisLabel: {
+ show: settings.showTickLabels,
+ color: yAxisTickLabelStyle.color,
+ fontStyle: yAxisTickLabelStyle.fontStyle,
+ fontWeight: yAxisTickLabelStyle.fontWeight,
+ fontFamily: yAxisTickLabelStyle.fontFamily,
+ fontSize: yAxisTickLabelStyle.fontSize,
+ formatter: (value: any) => formatValue(value, decimals, units, false)
+ },
+ splitLine: {
+ show: settings.showSplitLines,
+ lineStyle: {
+ color: prepareChartThemeColor(settings.splitLinesColor, darkMode, 'axis.splitLine')
+ }
+ }
+ }
+ };
+ if (settings.intervalCalculator && settings.intervalCalculator.length) {
+ yAxis.intervalCalculator = parseFunction(settings.intervalCalculator, ['axis']);
+ }
+ return yAxis;
+};
+
+export const createTimeSeriesXAxisOption = (settings: TimeSeriesChartAxisSettings,
+ min: number, max: number, darkMode: boolean): XAXisOption => {
+ const xAxisTickLabelStyle = createChartTextStyle(settings.tickLabelFont,
+ settings.tickLabelColor, darkMode, 'axis.tickLabel');
+ const xAxisNameStyle = createChartTextStyle(settings.labelFont,
+ settings.labelColor, darkMode, 'axis.label');
+ return {
+ show: settings.show,
+ type: 'time',
+ scale: true,
+ position: settings.position,
+ name: settings.label,
+ nameLocation: 'middle',
+ nameTextStyle: {
+ color: xAxisNameStyle.color,
+ fontStyle: xAxisNameStyle.fontStyle,
+ fontWeight: xAxisNameStyle.fontWeight,
+ fontFamily: xAxisNameStyle.fontFamily,
+ fontSize: xAxisNameStyle.fontSize
+ },
+ axisTick: {
+ show: settings.showTicks,
+ lineStyle: {
+ color: prepareChartThemeColor(settings.ticksColor, darkMode, 'axis.ticks')
+ }
+ },
+ axisLabel: {
+ show: settings.showTickLabels,
+ color: xAxisTickLabelStyle.color,
+ fontStyle: xAxisTickLabelStyle.fontStyle,
+ fontWeight: xAxisTickLabelStyle.fontWeight,
+ fontFamily: xAxisTickLabelStyle.fontFamily,
+ fontSize: xAxisTickLabelStyle.fontSize,
+ hideOverlap: true
+ },
+ axisLine: {
+ show: settings.showLine,
+ onZero: false,
+ lineStyle: {
+ color: prepareChartThemeColor(settings.lineColor, darkMode, 'axis.line')
+ }
+ },
+ splitLine: {
+ show: settings.showSplitLines,
+ lineStyle: {
+ color: prepareChartThemeColor(settings.splitLinesColor, darkMode, 'axis.splitLine')
+ }
+ },
+ min,
+ max
+ };
+};
+
+export const generateChartData = (dataItems: TimeSeriesChartDataItem[],
+ thresholdItems: TimeSeriesChartThresholdItem[],
+ timeInterval: Interval,
+ stack: boolean,
+ darkMode: boolean): Array => {
+ let series = generateChartSeries(dataItems, timeInterval, stack, darkMode);
+ if (thresholdItems.length) {
+ const thresholds = generateChartThresholds(thresholdItems, darkMode);
+ series = series.concat(thresholds);
+ }
+ return series;
+};
+
+export const calculateThresholdsOffset = (chart: ECharts,
+ thresholdItems: TimeSeriesChartThresholdItem[],
+ yAxisList: TimeSeriesChartYAxis[]): [number, number] => {
+ const result: [number, number] = [0, 0];
+ for (const item of thresholdItems) {
+ const yAxis = yAxisList[item.yAxisIndex];
+ const offset = measureThresholdLabelOffset(chart, yAxis.id, item.id, item.value);
+ result[0] = Math.max(result[0], offset[0]);
+ result[1] = Math.max(result[1], offset[1]);
+ }
+ return result;
+};
+
+export const parseThresholdData = (value: any): TimeSeriesChartThresholdValue => {
+ let thresholdValue: TimeSeriesChartThresholdValue;
+ if (Array.isArray(value)) {
+ thresholdValue = value;
+ } else {
+ try {
+ const parsedData = JSON.parse(value);
+ thresholdValue = Array.isArray(parsedData) ? parsedData : [parsedData];
+ } catch (e) {
+ thresholdValue = [value];
+ }
+ }
+ return thresholdValue;
+};
+
+const generateChartThresholds = (thresholdItems: TimeSeriesChartThresholdItem[], darkMode: boolean): Array => {
+ const series: Array = [];
+ for (const item of thresholdItems) {
+ if (isDefinedAndNotNull(item.value)) {
+ let seriesOption = item.option;
+ if (!item.option) {
+ const thresholdLabelStyle = createChartTextStyle(item.settings.labelFont,
+ item.settings.labelColor, darkMode, 'threshold.label');
+ seriesOption = {
+ type: 'line',
+ id: item.id,
+ dataGroupId: item.id,
+ yAxisIndex: item.yAxisIndex,
+ animation: true,
+ data: [],
+ tooltip: {
+ show: false
+ },
+ markLine: {
+ animation: true,
+ symbol: [item.settings.startSymbol, item.settings.endSymbol],
+ symbolSize: [item.settings.startSymbolSize, item.settings.endSymbolSize],
+ lineStyle: {
+ width: item.settings.lineWidth,
+ color: prepareChartThemeColor(item.settings.lineColor, darkMode, 'threshold.line'),
+ type: item.settings.lineType
+ },
+ label: {
+ show: item.settings.showLabel,
+ position: item.settings.labelPosition,
+ color: thresholdLabelStyle.color,
+ fontStyle: thresholdLabelStyle.fontStyle,
+ fontWeight: thresholdLabelStyle.fontWeight,
+ fontFamily: thresholdLabelStyle.fontFamily,
+ fontSize: thresholdLabelStyle.fontSize,
+ formatter: params => formatValue(params.value, item.decimals,
+ item.units, false)
+ },
+ emphasis: {
+ disabled: true
+ }
+ }
+ };
+ item.option = seriesOption;
+ }
+ seriesOption.markLine.data = [];
+ if (Array.isArray(item.value)) {
+ for (const val of item.value) {
+ seriesOption.markLine.data.push({
+ yAxis: val
+ });
+ }
+ } else {
+ seriesOption.markLine.data.push({
+ yAxis: item.value
+ });
+ }
+ series.push(seriesOption);
+ }
+ }
+ return series;
+};
+
+const generateChartSeries = (dataItems: TimeSeriesChartDataItem[],
+ timeInterval: Interval,
+ stack: boolean,
+ darkMode: boolean): Array => {
+ const series: Array = [];
+ const enabledDataItems = dataItems.filter(d => d.enabled);
+ const barDataItems = enabledDataItems.filter(d =>
+ d.dataKey.settings.type === TimeSeriesChartSeriesType.bar && d.data.length);
+ let barsCount = barDataItems.length;
+ const barGroups: number[] = [];
+ if (stack) {
+ barDataItems.forEach(item => {
+ if (barGroups.indexOf(item.yAxisIndex) === -1) {
+ barGroups.push(item.yAxisIndex);
+ }
+ });
+ barsCount = barGroups.length;
+ }
+ for (const item of enabledDataItems) {
+ if (item.dataKey.settings.type === TimeSeriesChartSeriesType.bar) {
+ if (!item.barRenderContext) {
+ item.barRenderContext = {};
+ }
+ item.barRenderContext.barsCount = barsCount;
+ item.barRenderContext.barIndex = stack ? barGroups.indexOf(item.yAxisIndex) : barDataItems.indexOf(item);
+ item.barRenderContext.timeInterval = timeInterval;
+ if (stack) {
+ const stackItems = enabledDataItems.filter(d => d.yAxisIndex === item.yAxisIndex);
+ item.barRenderContext.currentStackItems = stackItems;
+ item.barRenderContext.barStackIndex = stackItems.indexOf(item);
+ }
+ }
+ const seriesOption = createTimeSeriesChartSeries(item, stack, darkMode);
+ series.push(seriesOption);
+ }
+ return series;
+};
+
+export const updateDarkMode = (options: EChartsOption, settings: TimeSeriesChartSettings,
+ dataItems: TimeSeriesChartDataItem[], thresholdDataItems: TimeSeriesChartThresholdItem[],
+ darkMode: boolean): EChartsOption => {
+ options.darkMode = darkMode;
+ if (Array.isArray(options.yAxis)) {
+ for (const yAxis of options.yAxis) {
+ yAxis.nameTextStyle.color = prepareChartThemeColor(settings.yAxis.labelColor, darkMode, 'axis.label');
+ yAxis.axisLabel.color = prepareChartThemeColor(settings.yAxis.tickLabelColor, darkMode, 'axis.tickLabel');
+ yAxis.axisLine.lineStyle.color = prepareChartThemeColor(settings.yAxis.lineColor, darkMode, 'axis.line');
+ yAxis.axisTick.lineStyle.color = prepareChartThemeColor(settings.yAxis.ticksColor, darkMode, 'axis.ticks');
+ yAxis.splitLine.lineStyle.color = prepareChartThemeColor(settings.yAxis.splitLinesColor, darkMode, 'axis.splitLine');
+ }
+ }
+ if (Array.isArray(options.xAxis)) {
+ for (const xAxis of options.xAxis) {
+ xAxis.nameTextStyle.color = prepareChartThemeColor(settings.xAxis.labelColor, darkMode, 'axis.label');
+ xAxis.axisLabel.color = prepareChartThemeColor(settings.xAxis.tickLabelColor, darkMode, 'axis.tickLabel');
+ xAxis.axisLine.lineStyle.color = prepareChartThemeColor(settings.xAxis.lineColor, darkMode, 'axis.line');
+ xAxis.axisTick.lineStyle.color = prepareChartThemeColor(settings.xAxis.ticksColor, darkMode, 'axis.ticks');
+ xAxis.splitLine.lineStyle.color = prepareChartThemeColor(settings.xAxis.splitLinesColor, darkMode, 'axis.splitLine');
+ }
+ }
+ for (const item of dataItems) {
+ if (item.dataKey.settings.type === TimeSeriesChartSeriesType.line) {
+ if (item.option.label?.show) {
+ item.option.label.rich.value.color = prepareChartThemeColor(item.dataKey.settings.pointLabelColor, darkMode, 'series.pointLabel');
+ }
+ if (Array.isArray(options.series)) {
+ const series = options.series.find(s => s.id === item.id);
+ if (series) {
+ if (series.label?.show) {
+ series.label.rich.value.color = prepareChartThemeColor(item.dataKey.settings.pointLabelColor, darkMode, 'series.pointLabel');
+ }
+ }
+ }
+ } else {
+ if (item.barRenderContext?.labelOption?.show) {
+ item.barRenderContext.labelOption.rich.value.color = prepareChartThemeColor(item.dataKey.settings.pointLabelColor,
+ darkMode, 'series.pointLabel');
+ }
+ }
+ }
+ for (const item of thresholdDataItems) {
+ if (Array.isArray(options.series)) {
+ const series = options.series.find(s => s.id === item.id);
+ if (series) {
+ series.markLine.lineStyle.color = prepareChartThemeColor(item.settings.lineColor, darkMode, 'threshold.line');
+ if (series.markLine?.label?.show) {
+ series.markLine.label.color = prepareChartThemeColor(item.settings.labelColor, darkMode, 'threshold.label');
+ }
+ }
+ }
+ }
+ return options;
+};
+
+const createTimeSeriesChartSeries = (item: TimeSeriesChartDataItem,
+ stack: boolean,
+ darkMode: boolean): LineSeriesOption | CustomSeriesOption => {
+ let seriesOption = item.option;
+ if (!seriesOption) {
+ const dataKey = item.dataKey;
+ const settings: TimeSeriesChartKeySettings = dataKey.settings;
+ const seriesColor = item.dataKey.color;
+ let pointLabelStyle: ComponentStyle = {};
+ if (settings.showPointLabel) {
+ pointLabelStyle = createChartTextStyle(settings.pointLabelFont, settings.pointLabelColor, darkMode, 'series.pointLabel');
+ }
+ const label: SeriesLabelOption = {
+ show: settings.showPointLabel,
+ position: settings.pointLabelPosition,
+ formatter: (params): string => {
+ const value = formatValue(params.value[1], item.decimals, item.units, false);
+ return `{value|${value}}`;
+ },
+ rich: {
+ value: pointLabelStyle
+ }
+ };
+ seriesOption = {
+ id: item.id,
+ dataGroupId: item.id,
+ yAxisIndex: item.yAxisIndex,
+ name: item.dataKey.label,
+ color: seriesColor,
+ stack: stack ? (item.yAxisIndex + '') : undefined,
+ stackStrategy: 'all',
+ emphasis: {
+ focus: 'series'
+ },
+ animation: true,
+ dimensions: [
+ {name: 'intervalStart', type: 'number'},
+ {name: 'intervalEnd', type: 'number'}
+ ],
+ encode: {
+ intervalStart: 2,
+ intervalEnd: 3
+ }
+ };
+ item.option = seriesOption;
+ if (settings.type === TimeSeriesChartSeriesType.line) {
+ const lineSettings = settings.lineSettings;
+ const lineSeriesOption = seriesOption as LineSeriesOption;
+ lineSeriesOption.type = 'line';
+ lineSeriesOption.label = label;
+ lineSeriesOption.step = lineSettings.step;
+ lineSeriesOption.smooth = lineSettings.smooth;
+ lineSeriesOption.lineStyle = {
+ width: lineSettings.showLine ? lineSettings.lineWidth : 0,
+ type: lineSettings.lineType
+ };
+ if (lineSettings.fillAreaSettings.type !== SeriesFillType.none) {
+ lineSeriesOption.areaStyle = {};
+ if (lineSettings.fillAreaSettings.type === SeriesFillType.opacity) {
+ lineSeriesOption.areaStyle.opacity = lineSettings.fillAreaSettings.opacity;
+ } else {
+ lineSeriesOption.areaStyle.opacity = 1;
+ lineSeriesOption.areaStyle.color = createLinearOpacityGradient(seriesColor, lineSettings.fillAreaSettings.gradient);
+ }
+ }
+ lineSeriesOption.showSymbol = lineSettings.showPoints;
+ lineSeriesOption.symbol = lineSettings.pointShape;
+ lineSeriesOption.symbolSize = lineSettings.pointSize;
+ } else {
+ const barSettings = settings.barSettings;
+ const barSeriesOption = (seriesOption as any) as CustomSeriesOption;
+ barSeriesOption.type = 'custom';
+ const barVisualSettings: BarVisualSettings = {
+ color: seriesColor,
+ borderColor: seriesColor,
+ borderWidth: barSettings.showBorder ? barSettings.borderWidth : 0,
+ borderRadius: barSettings.borderRadius
+ };
+ if (barSettings.backgroundSettings.type === SeriesFillType.none) {
+ barVisualSettings.color = seriesColor;
+ } else if (barSettings.backgroundSettings.type === SeriesFillType.opacity) {
+ barVisualSettings.color = tinycolor(seriesColor).setAlpha(barSettings.backgroundSettings.opacity).toRgbString();
+ } else {
+ barVisualSettings.color = createLinearOpacityGradient(seriesColor, barSettings.backgroundSettings.gradient);
+ }
+ item.barRenderContext.visualSettings = barVisualSettings;
+ item.barRenderContext.labelOption = label;
+ barSeriesOption.renderItem = (params, api) =>
+ renderTimeSeriesBar(params, api, item.barRenderContext);
+ }
+ }
+ seriesOption.data = item.data;
+ return seriesOption;
+};
+
+const createChartTextStyle = (font: Font, color: string, darkMode: boolean, colorKey?: string): ComponentStyle => {
+ const style = textStyle(font);
+ delete style.lineHeight;
+ style.fontSize = font.size;
+ style.color = prepareChartThemeColor(color, darkMode, colorKey);
+ return style;
+};
+
+const createLinearOpacityGradient = (color: string, gradient: {start: number; end: number}): LinearGradientObject => ({
+ type: 'linear',
+ x: 0,
+ y: 0,
+ x2: 0,
+ y2: 1,
+ colorStops: [{
+ offset: 0, color: tinycolor(color).setAlpha(gradient.start / 100).toRgbString() // color at 0%
+ }, {
+ offset: 1, color: tinycolor(color).setAlpha(gradient.end / 100).toRgbString() // color at 100%
+ }],
+ global: false
+});
+
+const prepareChartThemeColor = (color: string, darkMode: boolean, colorKey?: string): string => {
+ if (darkMode) {
+ let colorInstance = tinycolor(color);
+ if (colorInstance.isDark()) {
+ if (colorKey && timeSeriesChartColorScheme[colorKey]) {
+ return timeSeriesChartColorScheme[colorKey].dark;
+ } else {
+ const rgb = colorInstance.toRgb();
+ colorInstance = tinycolor({r: 255 - rgb.r, g: 255 - rgb.g, b: 255 - rgb.b, a: rgb.a});
+ return colorInstance.toRgbString();
+ }
+ }
+ }
+ return color;
+};
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts
new file mode 100644
index 0000000000..48c2dfaf57
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts
@@ -0,0 +1,642 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { WidgetContext } from '@home/models/widget-component.models';
+import {
+ AxisPosition,
+ calculateThresholdsOffset,
+ createTimeSeriesXAxisOption,
+ createTimeSeriesYAxis,
+ generateChartData,
+ TimeSeriesChartDataItem,
+ timeSeriesChartDefaultSettings,
+ timeSeriesChartKeyDefaultSettings,
+ TimeSeriesChartKeySettings,
+ TimeSeriesChartSeriesType,
+ TimeSeriesChartSettings,
+ TimeSeriesChartThresholdItem,
+ TimeSeriesChartThresholdType,
+ TimeSeriesChartYAxis,
+ parseThresholdData,
+ PointLabelPosition,
+ updateDarkMode
+} from '@home/components/widget/lib/chart/time-series-chart.models';
+import { ResizeObserver } from '@juggle/resize-observer';
+import {
+ calculateXAxisHeight,
+ calculateYAxisWidth,
+ ECharts,
+ echartsModule,
+ EChartsOption,
+ echartsTooltipFormatter,
+ EChartsTooltipTrigger,
+ getAxisExtent,
+ getYAxis,
+ measureXAxisNameHeight,
+ measureYAxisNameWidth,
+ toNamedData
+} from '@home/components/widget/lib/chart/echarts-widget.models';
+import { DateFormatProcessor } from '@shared/models/widget-settings.models';
+import { isDefinedAndNotNull, 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 { Renderer2 } from '@angular/core';
+import { CustomSeriesOption, LineSeriesOption } from 'echarts/charts';
+import { BehaviorSubject } from 'rxjs';
+import { AggregationType } from '@shared/models/time/time.models';
+import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
+import { WidgetSubscriptionOptions } from '@core/api/widget-api.models';
+
+export class TbTimeSeriesChart {
+
+ private readonly shapeResize$: ResizeObserver;
+
+ private dataItems: TimeSeriesChartDataItem[] = [];
+ private thresholdItems: TimeSeriesChartThresholdItem[] = [];
+ private yAxisList: TimeSeriesChartYAxis[] = [];
+
+ private timeSeriesChart: ECharts;
+ private timeSeriesChartOptions: EChartsOption;
+
+ private readonly tooltipDateFormat: DateFormatProcessor;
+
+ private yMinSubject = new BehaviorSubject(-1);
+ private yMaxSubject = new BehaviorSubject(1);
+
+ private darkMode = false;
+
+ private messageChannel = new BroadcastChannel('tbMessageChannel');
+
+ private topPointLabels = false;
+
+ private componentIndexCounter = 0;
+
+ private highlightedDataKey: DataKey;
+
+ yMin$ = this.yMinSubject.asObservable();
+ yMax$ = this.yMaxSubject.asObservable();
+
+ constructor(private ctx: WidgetContext,
+ private readonly settings: TimeSeriesChartSettings,
+ private chartElement: HTMLElement,
+ private renderer: Renderer2,
+ private autoResize = true) {
+
+ this.settings = mergeDeep({} as TimeSeriesChartSettings, timeSeriesChartDefaultSettings, this.settings);
+ this.darkMode = this.settings.darkMode;
+ this.setupData();
+ this.setupThresholds();
+ if (this.settings.showTooltip && this.settings.tooltipShowDate) {
+ this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat);
+ }
+ this.onResize();
+ if (this.autoResize) {
+ this.shapeResize$ = new ResizeObserver(() => {
+ this.onResize();
+ });
+ this.shapeResize$.observe(this.chartElement);
+ }
+ this.messageChannel.addEventListener('message', (event) => {
+ if (event?.data?.type === 'tbDarkMode') {
+ const darkMode = !!event?.data?.darkMode;
+ this.setDarkMode(darkMode);
+ }
+ });
+ }
+
+ public update(): void {
+ for (const item of this.dataItems) {
+ const datasourceData = this.ctx.data ? this.ctx.data.find(d => d.dataKey === item.dataKey) : null;
+ item.data = datasourceData?.data ? toNamedData(datasourceData.data) : [];
+ }
+ this.onResize();
+ if (this.timeSeriesChart) {
+ this.timeSeriesChartOptions.xAxis[0].min = this.ctx.defaultSubscription.timeWindow.minTime;
+ this.timeSeriesChartOptions.xAxis[0].max = this.ctx.defaultSubscription.timeWindow.maxTime;
+ this.timeSeriesChartOptions.xAxis[0].tbTimeWindow = this.ctx.defaultSubscription.timeWindow;
+ if (this.ctx.defaultSubscription.timeWindowConfig?.aggregation?.type === AggregationType.NONE) {
+ this.timeSeriesChartOptions.tooltip[0].axisPointer.type = 'line';
+ } else {
+ this.timeSeriesChartOptions.tooltip[0].axisPointer.type = 'shadow';
+ }
+ this.updateSeriesData(true);
+ if (this.highlightedDataKey) {
+ this.keyEnter(this.highlightedDataKey);
+ }
+ }
+ }
+
+ public latestUpdated() {
+ let update = false;
+ if (this.ctx.latestData) {
+ for (const item of this.thresholdItems) {
+ if (item.settings.type === TimeSeriesChartThresholdType.latestKey && item.latestDataKey) {
+ const data = this.ctx.latestData.find(d => d.dataKey === item.latestDataKey);
+ if (data.data[0]) {
+ item.value = parseThresholdData(data.data[0][1]);
+ update = true;
+ }
+ }
+ }
+ }
+ if (this.timeSeriesChart && update) {
+ this.updateSeriesData();
+ }
+ }
+
+ public keyEnter(dataKey: DataKey): void {
+ this.highlightedDataKey = dataKey;
+ const item = this.dataItems.find(d => d.dataKey === dataKey);
+ if (item) {
+ this.timeSeriesChart.dispatchAction({
+ type: 'highlight',
+ seriesId: item.id
+ });
+ }
+ }
+
+ public keyLeave(dataKey: DataKey): void {
+ this.highlightedDataKey = null;
+ const item = this.dataItems.find(d => d.dataKey === dataKey);
+ if (item) {
+ this.timeSeriesChart.dispatchAction({
+ type: 'downplay',
+ seriesId: item.id
+ });
+ }
+ }
+
+ public toggleKey(dataKey: DataKey): void {
+ const enable = dataKey.hidden;
+ const dataItem = this.dataItems.find(d => d.dataKey === dataKey);
+ if (dataItem) {
+ dataItem.enabled = enable;
+ if (!enable) {
+ this.timeSeriesChart.dispatchAction({
+ type: 'downplay',
+ seriesId: dataItem.id
+ });
+ }
+ this.timeSeriesChartOptions.series = this.updateSeries();
+ const mergeList = ['series'];
+ if (this.updateYAxisScale(this.yAxisList)) {
+ this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
+ mergeList.push('yAxis');
+ }
+ this.timeSeriesChart.setOption(this.timeSeriesChartOptions, this.settings.stack ? {notMerge: true} : {replaceMerge: mergeList});
+ this.updateAxes();
+ dataKey.hidden = !enable;
+ if (enable) {
+ this.timeSeriesChart.dispatchAction({
+ type: 'highlight',
+ seriesId: dataItem.id
+ });
+ }
+ }
+ }
+
+ public destroy(): void {
+ if (this.shapeResize$) {
+ this.shapeResize$.disconnect();
+ }
+ if (this.timeSeriesChart) {
+ this.timeSeriesChart.dispose();
+ }
+ this.yMinSubject.complete();
+ this.yMaxSubject.complete();
+ this.messageChannel.close();
+ }
+
+ public resize(): void {
+ this.onResize();
+ }
+
+ public setDarkMode(darkMode: boolean): void {
+ if (this.darkMode !== darkMode) {
+ this.darkMode = darkMode;
+ if (this.timeSeriesChart) {
+ this.timeSeriesChartOptions = updateDarkMode(this.timeSeriesChartOptions, this.settings, this.dataItems,
+ this.thresholdItems, darkMode);
+ this.timeSeriesChart.setOption(this.timeSeriesChartOptions);
+ }
+ }
+ }
+
+ public isDarkMode(): boolean {
+ return this.darkMode;
+ }
+
+ private setupData(): void {
+ if (this.ctx.datasources.length) {
+ for (const datasource of this.ctx.datasources) {
+ const dataKeys = datasource.dataKeys;
+ for (const dataKey of dataKeys) {
+ const keySettings = mergeDeep({} as TimeSeriesChartKeySettings,
+ timeSeriesChartKeyDefaultSettings, dataKey.settings);
+ if (keySettings.showPointLabel && keySettings.pointLabelPosition === PointLabelPosition.top) {
+ this.topPointLabels = true;
+ }
+ dataKey.settings = keySettings;
+ const datasourceData = this.ctx.data ? this.ctx.data.find(d => d.dataKey === dataKey) : null;
+ const namedData = datasourceData?.data ? toNamedData(datasourceData.data) : [];
+ const units = dataKey.units && dataKey.units.length ? dataKey.units : this.ctx.units;
+ const decimals = isDefinedAndNotNull(dataKey.decimals) ? dataKey.decimals :
+ (isDefinedAndNotNull(this.ctx.decimals) ? this.ctx.decimals : 2);
+ this.dataItems.push({
+ id: this.nextComponentId(),
+ units,
+ decimals,
+ yAxisIndex: this.getYAxis(units, decimals),
+ dataKey,
+ data: namedData,
+ enabled: !keySettings.dataHiddenByDefault
+ });
+ }
+ }
+ }
+ }
+
+ private setupThresholds(): void {
+ const thresholdDatasources: Datasource[] = [];
+ for (const threshold of this.settings.thresholds) {
+ let latestDataKey: DataKey = null;
+ let entityDataKey: DataKey = null;
+ let value = null;
+ if (threshold.type === TimeSeriesChartThresholdType.latestKey) {
+ if (this.ctx.datasources.length) {
+ for (const datasource of this.ctx.datasources) {
+ latestDataKey = datasource.latestDataKeys?.find(d =>
+ (d.type === DataKeyType.function && d.label === threshold.latestKeyName) ||
+ (d.type !== DataKeyType.function && d.name === threshold.latestKeyName));
+ if (latestDataKey) {
+ break;
+ }
+ }
+ }
+ if (!latestDataKey) {
+ continue;
+ }
+ } else if (threshold.type === TimeSeriesChartThresholdType.entity) {
+ const entityAliasId = this.ctx.aliasController.getEntityAliasId(threshold.entityAlias);
+ if (!entityAliasId) {
+ continue;
+ }
+ let datasource = thresholdDatasources.find(d => d.entityAliasId === entityAliasId);
+ entityDataKey = {
+ type: threshold.entityKeyType,
+ name: threshold.entityKey,
+ label: threshold.entityKey,
+ settings: {}
+ };
+ if (datasource) {
+ datasource.dataKeys.push(entityDataKey);
+ }
+ datasource = {
+ type: DatasourceType.entity,
+ name: threshold.entityAlias,
+ aliasName: threshold.entityAlias,
+ entityAliasId,
+ dataKeys: [ entityDataKey ]
+ };
+ thresholdDatasources.push(datasource);
+ } else { // constant
+ value = threshold.value;
+ }
+ const units = threshold.units && threshold.units.length ? threshold.units : this.ctx.units;
+ const decimals = isDefinedAndNotNull(threshold.decimals) ? threshold.decimals :
+ (isDefinedAndNotNull(this.ctx.decimals) ? this.ctx.decimals : 2);
+ const thresholdItem: TimeSeriesChartThresholdItem = {
+ id: this.nextComponentId(),
+ units,
+ decimals,
+ yAxisIndex: this.getYAxis(units, decimals),
+ value,
+ latestDataKey,
+ settings: threshold
+ };
+ if (entityDataKey) {
+ entityDataKey.settings.thresholdItemId = thresholdItem.id;
+ }
+ this.thresholdItems.push(thresholdItem);
+ }
+ this.subscribeForEntityThresholds(thresholdDatasources);
+ }
+
+ private nextComponentId(): string {
+ return (this.componentIndexCounter++) + '';
+ }
+
+ private getYAxis(units: string, decimals: number): number {
+ let yAxisIndex = this.yAxisList.findIndex(axis => axis.units === units);
+ if (yAxisIndex === -1) {
+ const yAxisId = this.yAxisList.length + '';
+ const yAxis = createTimeSeriesYAxis(yAxisId, units, decimals, this.settings.yAxis, this.darkMode);
+ this.yAxisList.push(yAxis);
+ yAxisIndex = this.yAxisList.length - 1;
+ }
+ return yAxisIndex;
+ }
+
+ private subscribeForEntityThresholds(datasources: Datasource[]) {
+ if (datasources.length) {
+ const thresholdsSourcesSubscriptionOptions: WidgetSubscriptionOptions = {
+ datasources,
+ useDashboardTimewindow: false,
+ type: widgetType.latest,
+ callbacks: {
+ onDataUpdated: (subscription) => {
+ let update = false;
+ if (subscription.data) {
+ for (const item of this.thresholdItems) {
+ if (item.settings.type === TimeSeriesChartThresholdType.entity) {
+ const data = subscription.data.find(d => d.dataKey.settings?.thresholdItemId === item.id);
+ if (data.data[0]) {
+ item.value = parseThresholdData(data.data[0][1]);
+ update = true;
+ }
+ }
+ }
+ }
+ if (this.timeSeriesChart && update) {
+ this.updateSeriesData();
+ }
+ }
+ }
+ };
+ this.ctx.subscriptionApi.createSubscription(thresholdsSourcesSubscriptionOptions, true).subscribe();
+ }
+ }
+
+ private drawChart() {
+ echartsModule.init();
+ this.timeSeriesChart = echarts.init(this.chartElement, null, {
+ renderer: 'canvas'
+ });
+ const noAggregation = this.ctx.defaultSubscription.timeWindowConfig?.aggregation?.type === AggregationType.NONE;
+ this.timeSeriesChartOptions = {
+ darkMode: this.darkMode,
+ backgroundColor: 'transparent',
+ tooltip: [{
+ trigger: this.settings.tooltipTrigger === EChartsTooltipTrigger.axis ? 'axis' : 'item',
+ confine: true,
+ appendToBody: true,
+ axisPointer: {
+ type: noAggregation ? 'line' : 'shadow'
+ },
+ formatter: (params: CallbackDataParams[]) =>
+ this.settings.showTooltip ? echartsTooltipFormatter(this.renderer, this.tooltipDateFormat,
+ this.settings, params, 0, '', -1, this.dataItems) : undefined,
+ padding: [8, 12],
+ backgroundColor: this.settings.tooltipBackgroundColor,
+ borderWidth: 0,
+ extraCssText: `line-height: 1; backdrop-filter: blur(${this.settings.tooltipBackgroundBlur}px);`
+ }],
+ grid: [{
+ top: this.minTopOffset(),
+ left: this.settings.dataZoom ? 5 : 0,
+ right: this.settings.dataZoom ? 5 : 0,
+ bottom: this.minBottomOffset()
+ }],
+ xAxis: [
+ createTimeSeriesXAxisOption(this.settings.xAxis, this.ctx.defaultSubscription.timeWindow.minTime,
+ this.ctx.defaultSubscription.timeWindow.maxTime, this.darkMode)
+ ],
+ yAxis: this.yAxisList.map(axis => axis.option),
+ dataZoom: [
+ {
+ type: 'inside',
+ disabled: !this.settings.dataZoom,
+ realtime: true
+ },
+ {
+ type: 'slider',
+ show: this.settings.dataZoom,
+ showDetail: false,
+ realtime: true,
+ bottom: 10
+ }
+ ]
+ };
+
+ this.timeSeriesChartOptions.xAxis[0].tbTimeWindow = this.ctx.defaultSubscription.timeWindow;
+
+ this.timeSeriesChartOptions.series = this.updateSeries();
+ if (this.updateYAxisScale(this.yAxisList)) {
+ this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
+ }
+
+ this.timeSeriesChart.setOption(this.timeSeriesChartOptions);
+ this.updateAxes();
+
+ if (this.settings.dataZoom) {
+ this.timeSeriesChart.on('datazoom', () => {
+ this.updateAxes();
+ });
+ }
+ }
+
+ private updateSeriesData(updateScale = false): void {
+ this.timeSeriesChartOptions.series = this.updateSeries();
+ if (updateScale && this.updateYAxisScale(this.yAxisList)) {
+ this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
+ }
+ this.timeSeriesChart.setOption(this.timeSeriesChartOptions);
+ this.updateAxes();
+ }
+
+ private updateSeries(): Array {
+ return generateChartData(this.dataItems, this.thresholdItems,
+ this.ctx.timeWindow.interval, this.settings.stack, this.darkMode);
+ }
+
+ private updateAxes() {
+ const leftAxisList = this.yAxisList.filter(axis => axis.option.position === 'left');
+ let res = this.updateYAxisOffset(leftAxisList);
+ let leftOffset = res.offset + (!res.offset && this.settings.dataZoom ? 5 : 0);
+ let changed = res.changed;
+ const rightAxisList = this.yAxisList.filter(axis => axis.option.position === 'right');
+ res = this.updateYAxisOffset(rightAxisList);
+ let rightOffset = res.offset + (!res.offset && this.settings.dataZoom ? 5 : 0);
+ changed = changed || res.changed;
+ let bottomOffset = this.minBottomOffset();
+ const minTopOffset = this.minTopOffset();
+ let topOffset = minTopOffset;
+ if (this.timeSeriesChartOptions.xAxis[0].show) {
+ const xAxisHeight = calculateXAxisHeight(this.timeSeriesChart);
+ if (this.timeSeriesChartOptions.xAxis[0].position === AxisPosition.bottom) {
+ bottomOffset += xAxisHeight;
+ } else {
+ topOffset = Math.max(minTopOffset, xAxisHeight);
+ }
+ if (this.settings.xAxis.label) {
+ const nameHeight = measureXAxisNameHeight(this.timeSeriesChart, this.timeSeriesChartOptions.xAxis[0].name);
+ if (this.timeSeriesChartOptions.xAxis[0].position === AxisPosition.bottom) {
+ bottomOffset += nameHeight;
+ } else {
+ topOffset = Math.max(minTopOffset, xAxisHeight + nameHeight);
+ }
+ const nameGap = xAxisHeight;
+ if (this.timeSeriesChartOptions.xAxis[0].nameGap !== nameGap) {
+ this.timeSeriesChartOptions.xAxis[0].nameGap = nameGap;
+ changed = true;
+ }
+ }
+ }
+
+ const thresholdsOffset = calculateThresholdsOffset(this.timeSeriesChart, this.thresholdItems, this.yAxisList);
+ leftOffset = Math.max(leftOffset, thresholdsOffset[0]);
+ rightOffset = Math.max(rightOffset, thresholdsOffset[1]);
+
+ if (this.timeSeriesChartOptions.grid[0].left !== leftOffset ||
+ this.timeSeriesChartOptions.grid[0].right !== rightOffset ||
+ this.timeSeriesChartOptions.grid[0].bottom !== bottomOffset ||
+ this.timeSeriesChartOptions.grid[0].top !== topOffset) {
+ this.timeSeriesChartOptions.grid[0].left = leftOffset;
+ this.timeSeriesChartOptions.grid[0].right = rightOffset;
+ this.timeSeriesChartOptions.grid[0].bottom = bottomOffset;
+ this.timeSeriesChartOptions.grid[0].top = topOffset;
+ changed = true;
+ }
+ if (changed) {
+ this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
+ this.timeSeriesChart.setOption(this.timeSeriesChartOptions, {replaceMerge: ['yAxis', 'xAxis', 'grid'], lazyUpdate: true});
+ }
+ changed = this.calculateYAxisInterval(this.yAxisList);
+ if (changed) {
+ this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
+ this.timeSeriesChart.setOption(this.timeSeriesChartOptions, {replaceMerge: ['yAxis'], lazyUpdate: true});
+ }
+ if (this.yAxisList.length) {
+ const extent = getAxisExtent(this.timeSeriesChart, this.yAxisList[0].id);
+ const min = extent[0];
+ const max = extent[1];
+ if (this.yMinSubject.value !== min) {
+ this.yMinSubject.next(min);
+ }
+ if (this.yMaxSubject.value !== max) {
+ this.yMaxSubject.next(max);
+ }
+ }
+ }
+
+ private updateYAxisScale(axisList: TimeSeriesChartYAxis[]): boolean {
+ let changed = false;
+ for (const yAxis of axisList) {
+ const scaleYAxis = this.scaleYAxis(yAxis);
+ if (yAxis.option.scale !== scaleYAxis) {
+ yAxis.option.scale = scaleYAxis;
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
+ private updateYAxisOffset(axisList: TimeSeriesChartYAxis[]): {offset: number; changed: boolean} {
+ const result = {offset: 0, changed: false};
+ let width = 0;
+ for (const yAxis of axisList) {
+ const newWidth = calculateYAxisWidth(this.timeSeriesChart, yAxis.id);
+ if (width && newWidth) {
+ result.offset += 5;
+ }
+ width = newWidth;
+ const showLine = !!width && this.settings.yAxis.showLine;
+ if (yAxis.option.axisLine.show !== showLine) {
+ yAxis.option.axisLine.show = showLine;
+ result.changed = true;
+ }
+ if (yAxis.option.offset !== result.offset) {
+ yAxis.option.offset = result.offset;
+ result.changed = true;
+ }
+ if (this.settings.yAxis.label) {
+ if (!width) {
+ if (yAxis.option.name) {
+ yAxis.option.name = null;
+ result.changed = true;
+ }
+ } else {
+ if (!yAxis.option.name) {
+ yAxis.option.name = this.settings.yAxis.label;
+ result.changed = true;
+ }
+ const nameGap = width;
+ if (yAxis.option.nameGap !== nameGap) {
+ yAxis.option.nameGap = nameGap;
+ result.changed = true;
+ }
+ const nameWidth = measureYAxisNameWidth(this.timeSeriesChart, yAxis.id, this.settings.yAxis.label);
+ result.offset += nameWidth;
+ }
+ }
+ result.offset += width;
+ }
+ return result;
+ }
+
+ private scaleYAxis(yAxis: TimeSeriesChartYAxis): boolean {
+ const yAxisIndex = this.yAxisList.indexOf(yAxis);
+ const axisBarDataItems = this.dataItems.filter(d => d.yAxisIndex === yAxisIndex && d.enabled &&
+ d.data.length && d.dataKey.settings.type === TimeSeriesChartSeriesType.bar);
+ return !axisBarDataItems.length;
+ }
+
+ private calculateYAxisInterval(axisList: TimeSeriesChartYAxis[]): boolean {
+ let changed = false;
+ for (const yAxis of axisList) {
+ if (yAxis.intervalCalculator) {
+ const axis = getYAxis(this.timeSeriesChart, yAxis.id);
+ if (axis) {
+ try {
+ const interval = yAxis.intervalCalculator(axis);
+ if ((yAxis.option as any).interval !== interval) {
+ (yAxis.option as any).interval = interval;
+ changed = true;
+ }
+ } catch (_e) {}
+ }
+ }
+ }
+ return changed;
+ }
+
+ private minTopOffset(): number {
+ return (this.topPointLabels) ? 20 :
+ ((this.settings.yAxis.show && this.settings.yAxis.showTickLabels) ? 10 : 5);
+ }
+
+ private minBottomOffset(): number {
+ return this.settings.dataZoom ? 45 : 5;
+ }
+
+ private onResize() {
+ const shapeWidth = this.chartElement.offsetWidth;
+ const shapeHeight = this.chartElement.offsetHeight;
+ if (shapeWidth && shapeHeight) {
+ if (!this.timeSeriesChart) {
+ this.drawChart();
+ } else {
+ const width = this.timeSeriesChart.getWidth();
+ const height = this.timeSeriesChart.getHeight();
+ if (width !== shapeWidth || height !== shapeHeight) {
+ this.timeSeriesChart.resize();
+ }
+ }
+ }
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
index 03bd97dd41..0ae45d1eff 100644
--- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
@@ -76,6 +76,7 @@ import { CommandButtonWidgetComponent } from '@home/components/widget/lib/button
import { PowerButtonWidgetComponent } from '@home/components/widget/lib/rpc/power-button-widget.component';
import { SliderWidgetComponent } from '@home/components/widget/lib/rpc/slider-widget.component';
import { ToggleButtonWidgetComponent } from '@home/components/widget/lib/button/toggle-button-widget.component';
+import { TimeSeriesChartWidgetComponent } from '@home/components/widget/lib/chart/time-series-chart-widget.component';
@NgModule({
declarations:
@@ -124,7 +125,8 @@ import { ToggleButtonWidgetComponent } from '@home/components/widget/lib/button/
CommandButtonWidgetComponent,
PowerButtonWidgetComponent,
SliderWidgetComponent,
- ToggleButtonWidgetComponent
+ ToggleButtonWidgetComponent,
+ TimeSeriesChartWidgetComponent
],
imports: [
CommonModule,
@@ -177,7 +179,8 @@ import { ToggleButtonWidgetComponent } from '@home/components/widget/lib/button/
CommandButtonWidgetComponent,
PowerButtonWidgetComponent,
SliderWidgetComponent,
- ToggleButtonWidgetComponent
+ ToggleButtonWidgetComponent,
+ TimeSeriesChartWidgetComponent
],
providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule }
diff --git a/ui-ngx/src/app/shared/models/color.models.ts b/ui-ngx/src/app/shared/models/color.models.ts
new file mode 100644
index 0000000000..7c1d08baf8
--- /dev/null
+++ b/ui-ngx/src/app/shared/models/color.models.ts
@@ -0,0 +1,24 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+export interface TbColor {
+ light: string;
+ dark: string;
+}
+
+export interface TbColorScheme {
+ [key: string]: TbColor;
+}
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index 288db8c9d2..96d8ddebf7 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -3169,6 +3169,11 @@
"avg": "avg",
"total": "total",
"latest": "latest",
+ "Min": "Min",
+ "Max": "Max",
+ "Avg": "Avg",
+ "Total": "Total",
+ "Latest": "Latest",
"comparison-time-ago": {
"previousInterval": "(previous interval)",
"customInterval": "(custom interval)",