Browse Source

UI: Implement Time series chart widget.

pull/10315/head
Igor Kulikov 3 years ago
parent
commit
d7cca23701
  1. 1
      application/src/main/data/json/system/widget_bundles/charts.json
  2. 34
      application/src/main/data/json/system/widget_types/time_series_chart.json
  3. 208
      ui-ngx/patches/echarts+5.4.3.patch
  4. 8
      ui-ngx/src/app/core/api/widget-subscription.ts
  5. 1
      ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss
  6. 93
      ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts
  7. 6
      ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.html
  8. 11
      ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.scss
  9. 100
      ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts
  10. 9
      ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts
  11. 1
      ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts
  12. 204
      ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts
  13. 1
      ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts
  14. 134
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-bar.models.ts
  15. 122
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html
  16. 181
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.scss
  17. 163
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts
  18. 52
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.models.ts
  19. 873
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts
  20. 642
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts
  21. 7
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  22. 24
      ui-ngx/src/app/shared/models/color.models.ts
  23. 5
      ui-ngx/src/assets/locale/locale.constant-en_US.json

1
application/src/main/data/json/system/widget_bundles/charts.json

@ -8,6 +8,7 @@
"name": "Charts" "name": "Charts"
}, },
"widgetTypeFqns": [ "widgetTypeFqns": [
"time_series_chart",
"charts.basic_timeseries", "charts.basic_timeseries",
"charts.state_chart", "charts.state_chart",
"range_chart", "range_chart",

34
application/src/main/data/json/system/widget_types/time_series_chart.json

File diff suppressed because one or more lines are too long

208
ui-ngx/patches/echarts+5.4.3.patch

@ -0,0 +1,208 @@
diff --git a/node_modules/echarts/lib/component/dataZoom/DataZoomModel.js b/node_modules/echarts/lib/component/dataZoom/DataZoomModel.js
index 112ebe3..9afc9b7 100644
--- a/node_modules/echarts/lib/component/dataZoom/DataZoomModel.js
+++ b/node_modules/echarts/lib/component/dataZoom/DataZoomModel.js
@@ -422,7 +422,10 @@ function (_super) {
return axisProxy.getDataValueWindow();
}
} else {
- return this.getAxisProxy(axisDim, axisIndex).getDataValueWindow();
+ var axisProxy = this.getAxisProxy(axisDim, axisIndex);
+ if (axisProxy) {
+ return axisProxy.getDataValueWindow();
+ }
}
};
/**
@@ -449,11 +452,11 @@ function (_super) {
for (var j = 0; j < axisInfo.indexList.length; j++) {
var proxy = this.getAxisProxy(axisDim, axisInfo.indexList[j]);
- if (proxy.hostedBy(this)) {
+ if (proxy && proxy.hostedBy(this)) {
return proxy;
}
- if (!firstProxy) {
+ if (proxy && !firstProxy) {
firstProxy = proxy;
}
}
diff --git a/node_modules/echarts/lib/component/dataZoom/InsideZoomView.js b/node_modules/echarts/lib/component/dataZoom/InsideZoomView.js
index 7163279..c37f9c2 100644
--- a/node_modules/echarts/lib/component/dataZoom/InsideZoomView.js
+++ b/node_modules/echarts/lib/component/dataZoom/InsideZoomView.js
@@ -112,12 +112,15 @@ var getRangeHandlers = {
range[0] = (range[0] - percentPoint) * scale + percentPoint;
range[1] = (range[1] - percentPoint) * scale + percentPoint; // Restrict range.
- var minMaxSpan = this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan();
- sliderMove(0, range, [0, 100], 0, minMaxSpan.minSpan, minMaxSpan.maxSpan);
- this.range = range;
-
- if (lastRange[0] !== range[0] || lastRange[1] !== range[1]) {
- return range;
+ var proxy = this.dataZoomModel.findRepresentativeAxisProxy();
+ if (proxy) {
+ var minMaxSpan = proxy.getMinMaxSpan();
+ sliderMove(0, range, [0, 100], 0, minMaxSpan.minSpan, minMaxSpan.maxSpan);
+ this.range = range;
+
+ if (lastRange[0] !== range[0] || lastRange[1] !== range[1]) {
+ return range;
+ }
}
},
pan: makeMover(function (range, axisModel, coordSysInfo, coordSysMainType, controller, e) {
diff --git a/node_modules/echarts/lib/component/dataZoom/SliderZoomView.js b/node_modules/echarts/lib/component/dataZoom/SliderZoomView.js
index 590ef51..aff8a0a 100644
--- a/node_modules/echarts/lib/component/dataZoom/SliderZoomView.js
+++ b/node_modules/echarts/lib/component/dataZoom/SliderZoomView.js
@@ -64,7 +64,7 @@ var DEFAULT_MOVE_HANDLE_SIZE = 7;
var HORIZONTAL = 'horizontal';
var VERTICAL = 'vertical';
var LABEL_GAP = 5;
-var SHOW_DATA_SHADOW_SERIES_TYPE = ['line', 'bar', 'candlestick', 'scatter'];
+var SHOW_DATA_SHADOW_SERIES_TYPE = ['line', 'bar', 'candlestick', 'scatter', 'custom'];
var REALTIME_ANIMATION_CONFIG = {
easing: 'cubicOut',
duration: 100,
@@ -406,34 +406,37 @@ function (_super) {
var result;
var ecModel = this.ecModel;
dataZoomModel.eachTargetAxis(function (axisDim, axisIndex) {
- var seriesModels = dataZoomModel.getAxisProxy(axisDim, axisIndex).getTargetSeriesModels();
- each(seriesModels, function (seriesModel) {
- if (result) {
- return;
- }
-
- if (showDataShadow !== true && indexOf(SHOW_DATA_SHADOW_SERIES_TYPE, seriesModel.get('type')) < 0) {
- return;
- }
-
- var thisAxis = ecModel.getComponent(getAxisMainType(axisDim), axisIndex).axis;
- var otherDim = getOtherDim(axisDim);
- var otherAxisInverse;
- var coordSys = seriesModel.coordinateSystem;
-
- if (otherDim != null && coordSys.getOtherAxis) {
- otherAxisInverse = coordSys.getOtherAxis(thisAxis).inverse;
- }
-
- otherDim = seriesModel.getData().mapDimension(otherDim);
- result = {
- thisAxis: thisAxis,
- series: seriesModel,
- thisDim: axisDim,
- otherDim: otherDim,
- otherAxisInverse: otherAxisInverse
- };
- }, this);
+ var axisProxy = dataZoomModel.getAxisProxy(axisDim, axisIndex);
+ if (axisProxy) {
+ var seriesModels = axisProxy.getTargetSeriesModels();
+ each(seriesModels, function (seriesModel) {
+ if (result) {
+ return;
+ }
+
+ if (showDataShadow !== true && indexOf(SHOW_DATA_SHADOW_SERIES_TYPE, seriesModel.get('type')) < 0) {
+ return;
+ }
+
+ var thisAxis = ecModel.getComponent(getAxisMainType(axisDim), axisIndex).axis;
+ var otherDim = getOtherDim(axisDim);
+ var otherAxisInverse;
+ var coordSys = seriesModel.coordinateSystem;
+
+ if (otherDim != null && coordSys.getOtherAxis) {
+ otherAxisInverse = coordSys.getOtherAxis(thisAxis).inverse;
+ }
+
+ otherDim = seriesModel.getData().mapDimension(otherDim);
+ result = {
+ thisAxis: thisAxis,
+ series: seriesModel,
+ thisDim: axisDim,
+ otherDim: otherDim,
+ otherAxisInverse: otherAxisInverse
+ };
+ }, this);
+ }
}, this);
return result;
};
@@ -595,12 +598,17 @@ function (_super) {
var viewExtend = this._getViewExtent();
- var minMaxSpan = dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan();
- var percentExtent = [0, 100];
- sliderMove(delta, handleEnds, viewExtend, dataZoomModel.get('zoomLock') ? 'all' : handleIndex, minMaxSpan.minSpan != null ? linearMap(minMaxSpan.minSpan, percentExtent, viewExtend, true) : null, minMaxSpan.maxSpan != null ? linearMap(minMaxSpan.maxSpan, percentExtent, viewExtend, true) : null);
- var lastRange = this._range;
- var range = this._range = asc([linearMap(handleEnds[0], viewExtend, percentExtent, true), linearMap(handleEnds[1], viewExtend, percentExtent, true)]);
- return !lastRange || lastRange[0] !== range[0] || lastRange[1] !== range[1];
+ var proxy = dataZoomModel.findRepresentativeAxisProxy();
+ if (proxy) {
+ var minMaxSpan = proxy.getMinMaxSpan();
+ var percentExtent = [0, 100];
+ sliderMove(delta, handleEnds, viewExtend, dataZoomModel.get('zoomLock') ? 'all' : handleIndex, minMaxSpan.minSpan != null ? linearMap(minMaxSpan.minSpan, percentExtent, viewExtend, true) : null, minMaxSpan.maxSpan != null ? linearMap(minMaxSpan.maxSpan, percentExtent, viewExtend, true) : null);
+ var lastRange = this._range;
+ var range = this._range = asc([linearMap(handleEnds[0], viewExtend, percentExtent, true), linearMap(handleEnds[1], viewExtend, percentExtent, true)]);
+ return !lastRange || lastRange[0] !== range[0] || lastRange[1] !== range[1];
+ } else {
+ return false;
+ }
};
SliderZoomView.prototype._updateView = function (nonRealtime) {
diff --git a/node_modules/echarts/lib/component/dataZoom/dataZoomProcessor.js b/node_modules/echarts/lib/component/dataZoom/dataZoomProcessor.js
index 3871784..9bab428 100644
--- a/node_modules/echarts/lib/component/dataZoom/dataZoomProcessor.js
+++ b/node_modules/echarts/lib/component/dataZoom/dataZoomProcessor.js
@@ -91,7 +91,10 @@ var dataZoomProcessor = {
// init stage and not after action dispatch handler, because
// reset should be called after seriesData.restoreData.
dataZoomModel.eachTargetAxis(function (axisDim, axisIndex) {
- dataZoomModel.getAxisProxy(axisDim, axisIndex).reset(dataZoomModel);
+ var axisProxy = dataZoomModel.getAxisProxy(axisDim, axisIndex);
+ if (axisProxy) {
+ axisProxy.reset(dataZoomModel);
+ }
}); // Caution: data zoom filtering is order sensitive when using
// percent range and no min/max/scale set on axis.
// For example, we have dataZoom definition:
@@ -108,7 +111,10 @@ var dataZoomProcessor = {
// and then reset y-axis and filter y-axis.
dataZoomModel.eachTargetAxis(function (axisDim, axisIndex) {
- dataZoomModel.getAxisProxy(axisDim, axisIndex).filterData(dataZoomModel, api);
+ var axisProxy = dataZoomModel.getAxisProxy(axisDim, axisIndex);
+ if (axisProxy) {
+ axisProxy.filterData(dataZoomModel, api);
+ }
});
});
ecModel.eachComponent('dataZoom', function (dataZoomModel) {
diff --git a/node_modules/echarts/lib/component/toolbox/feature/DataZoom.js b/node_modules/echarts/lib/component/toolbox/feature/DataZoom.js
index c5ee405..957ffd2 100644
--- a/node_modules/echarts/lib/component/toolbox/feature/DataZoom.js
+++ b/node_modules/echarts/lib/component/toolbox/feature/DataZoom.js
@@ -129,10 +129,13 @@ function (_super) {
var axisModel = axis.model;
var dataZoomModel = findDataZoom(dimName, axisModel, ecModel); // Restrict range.
- var minMaxSpan = dataZoomModel.findRepresentativeAxisProxy(axisModel).getMinMaxSpan();
+ var proxy = dataZoomModel.findRepresentativeAxisProxy(axisModel);
+ if (proxy) {
+ var minMaxSpan = proxy.getMinMaxSpan();
- if (minMaxSpan.minValueSpan != null || minMaxSpan.maxValueSpan != null) {
- minMax = sliderMove(0, minMax.slice(), axis.scale.getExtent(), 0, minMaxSpan.minValueSpan, minMaxSpan.maxValueSpan);
+ if (minMaxSpan.minValueSpan != null || minMaxSpan.maxValueSpan != null) {
+ minMax = sliderMove(0, minMax.slice(), axis.scale.getExtent(), 0, minMaxSpan.minValueSpan, minMaxSpan.maxValueSpan);
+ }
}
dataZoomModel && (snapshot[dataZoomModel.id] = {

8
ui-ngx/src/app/core/api/widget-subscription.ts

@ -61,9 +61,8 @@ import {
deepClone, deepClone,
flatFormattedData, flatFormattedData,
formattedDataFormDatasourceData, formattedDataFormDatasourceData,
isDefined,
isDefinedAndNotNull, isDefinedAndNotNull,
isEqual, isEqual, isUndefined,
parseHttpErrorMessage parseHttpErrorMessage
} from '@core/utils'; } from '@core/utils';
import { EntityId } from '@app/shared/models/id/entity-id'; import { EntityId } from '@app/shared/models/id/entity-id';
@ -355,7 +354,7 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
this.units = options.units || ''; this.units = options.units || '';
this.decimals = isDefined(options.decimals) ? options.decimals : 2; this.decimals = isDefinedAndNotNull(options.decimals) ? options.decimals : 2;
this.loadingData = false; this.loadingData = false;
@ -1491,7 +1490,8 @@ export class WidgetSubscription implements IWidgetSubscription {
let datasourceDataArray: Array<DatasourceData> = []; let datasourceDataArray: Array<DatasourceData> = [];
datasourceDataArray = datasourceDataArray.concat(datasource.dataKeys.map((dataKey, keyIndex) => { datasourceDataArray = datasourceDataArray.concat(datasource.dataKeys.map((dataKey, keyIndex) => {
dataKey.hidden = !!dataKey.settings.hideDataByDefault; dataKey.hidden = !!dataKey.settings.hideDataByDefault;
dataKey.inLegend = !dataKey.settings.removeFromLegend; dataKey.inLegend = dataKey.settings.showInLegend ||
(isUndefined(dataKey.settings.showInLegend) && !dataKey.settings.removeFromLegend);
dataKey.label = this.ctx.utils.customTranslation(dataKey.label, dataKey.label); dataKey.label = this.ctx.utils.customTranslation(dataKey.label, dataKey.label);
const datasourceData: DatasourceData = { const datasourceData: DatasourceData = {
datasource, datasource,

1
ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss

@ -92,6 +92,7 @@
.tb-aggregated-value-card-chart-container { .tb-aggregated-value-card-chart-container {
position: relative; position: relative;
flex: 1; flex: 1;
min-width: 0;
margin-top: 8px; margin-top: 8px;
margin-bottom: 8px; margin-bottom: 8px;
.tb-aggregated-value-card-chart-element { .tb-aggregated-value-card-chart-element {

93
ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts

@ -43,16 +43,21 @@ import {
getDataKey, getDataKey,
getLatestSingleTsValue, getLatestSingleTsValue,
overlayStyle, overlayStyle,
simpleDateFormat,
textStyle textStyle
} from '@shared/models/widget-settings.models'; } from '@shared/models/widget-settings.models';
import { TbFlot } from '@home/components/widget/lib/flot-widget';
import { TbFlotKeySettings, TbFlotSettings } from '@home/components/widget/lib/flot-widget.models';
import { DataKey } from '@shared/models/widget.models'; import { DataKey } from '@shared/models/widget.models';
import { formatNumberValue, formatValue, isDefined, isDefinedAndNotNull, isNumeric } from '@core/utils'; import { formatNumberValue, formatValue, isDefined, isDefinedAndNotNull, isNumeric } from '@core/utils';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ResizeObserver } from '@juggle/resize-observer'; import { ResizeObserver } from '@juggle/resize-observer';
import { ImagePipe } from '@shared/pipe/image.pipe'; import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser'; 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 valuesLayoutHeight = 66; const valuesLayoutHeight = 66;
const valuesLayoutVerticalPadding = 16; const valuesLayoutVerticalPadding = 16;
@ -101,8 +106,8 @@ export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit
backgroundStyle$: Observable<ComponentStyle>; backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {}; overlayStyle: ComponentStyle = {};
private flot: TbFlot; private lineChart: TbTimeSeriesChart;
private flotDataKey: DataKey; private lineChartDataKey: DataKey;
private lastUpdateTs: number; private lastUpdateTs: number;
@ -141,12 +146,17 @@ export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit
this.showChart = this.settings.showChart; this.showChart = this.settings.showChart;
if (this.showChart) { if (this.showChart) {
if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) { if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) {
this.flotDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0]; this.lineChartDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0];
this.flotDataKey.settings = { this.lineChartDataKey.settings = {
fillLines: false, showPointLabel: false,
showLines: true, type: TimeSeriesChartSeriesType.line,
lineWidth: 2 lineSettings: {
} as TbFlotKeySettings; smooth: false,
showLine: true,
lineWidth: 2,
showPoints: false
}
} as TimeSeriesChartKeySettings;
} }
} }
@ -161,33 +171,34 @@ export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (this.showChart && this.ctx.datasources?.length) { if (this.showChart && this.ctx.datasources?.length) {
const settings = { const settings: TimeSeriesChartSettings = {
shadowSize: 0, dataZoom: false,
enableSelection: false, xAxis: {
smoothLines: false, show: false
grid: { },
tickColor: 'rgba(0,0,0,0.12)', yAxis: {
horizontalLines: true, show: true,
verticalLines: false, showLine: false,
outlineWidth: 0, showTicks: false,
minBorderMargin: 0, showTickLabels: false,
margin: 0 showSplitLines: true,
}, min: 'dataMin',
yaxis: { max: 'dataMax',
showLabels: false, intervalCalculator:
tickGenerator: 'return [(axis.max + axis.min) / 2];' 'var scale = axis.scale; return !scale.isBlank() ? ((scale.getExtent()[1] - scale.getExtent()[0]) / 2) : undefined;'
}, },
xaxis: { tooltipDateInterval: false,
showLabels: false tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm:ss')
} } as TimeSeriesChartSettings;
} as TbFlotSettings;
this.flot = new TbFlot(this.ctx, 'line', $(this.chartElement.nativeElement), settings); this.lineChart = new TbTimeSeriesChart(this.ctx, settings, this.chartElement.nativeElement, this.renderer, true);
this.tickMin$ = this.flot.yMin$.pipe(
map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals)) this.tickMin$ =this.lineChart.yMin$.pipe(
map((value) => formatValue(value, (this.lineChartDataKey?.decimals || this.ctx.decimals))
));
this.tickMax$ = this.lineChart.yMax$.pipe(
map((value) => formatValue(value, (this.lineChartDataKey?.decimals || this.ctx.decimals))
)); ));
this.tickMax$ = this.flot.yMax$.pipe(
map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals))
));
} }
if (this.settings.autoScale && this.showValues) { if (this.settings.autoScale && this.showValues) {
this.renderer.setStyle(this.valueCardValueContainer.nativeElement, 'height', valuesLayoutHeight + 'px'); this.renderer.setStyle(this.valueCardValueContainer.nativeElement, 'height', valuesLayoutHeight + 'px');
@ -221,7 +232,7 @@ export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit
} }
if (this.showChart) { if (this.showChart) {
this.flot.update(); this.lineChart.update();
} }
this.updateLastUpdateTs(ts); this.updateLastUpdateTs(ts);
@ -256,20 +267,14 @@ export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit
} }
public onResize() { public onResize() {
if (this.showChart) {
this.flot.resize();
}
} }
public onEditModeChanged() { public onEditModeChanged() {
if (this.showChart) {
this.flot.checkMouseEvents();
}
} }
public onDestroy() { public onDestroy() {
if (this.showChart) { if (this.showChart) {
this.flot.destroy(); this.lineChart.destroy();
} }
} }

6
ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.html

@ -18,8 +18,10 @@
<div class="tb-value-chart-card-panel" [style]="backgroundStyle$ | async"> <div class="tb-value-chart-card-panel" [style]="backgroundStyle$ | async">
<div class="tb-value-chart-card-overlay" [style]="overlayStyle"></div> <div class="tb-value-chart-card-overlay" [style]="overlayStyle"></div>
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
<div #valueChartCardContent class="tb-value-chart-card-content" [class]="layout"> <div #valueChartCardContent class="tb-value-chart-card-content" [class]="layout" [class.auto-scale]="autoScale && showValue">
<div *ngIf="showValue" class="tb-value-chart-card-value" #valueChartCardValue [style]="valueStyle" [style.color]="valueColor.color">{{ valueText }}</div> <div *ngIf="showValue" class="tb-value-chart-card-value-container">
<div class="tb-value-chart-card-value" #valueChartCardValue [style]="valueStyle" [style.color]="valueColor.color">{{ valueText }}</div>
</div>
<div class="tb-value-chart-card-chart" #chartElement></div> <div class="tb-value-chart-card-chart" #chartElement></div>
</div> </div>
</div> </div>

11
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 { .tb-value-chart-card-chart {
flex: 1; flex: 1;
min-width: 0;
height: 100%; 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%;
}
}
} }
} }

100
ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts

@ -34,7 +34,9 @@ import {
ColorProcessor, ColorProcessor,
ComponentStyle, ComponentStyle,
getDataKey, getDataKey,
overlayStyle, resolveCssSize, overlayStyle,
resolveCssSize,
simpleDateFormat,
textStyle textStyle
} from '@shared/models/widget-settings.models'; } from '@shared/models/widget-settings.models';
import { WidgetComponent } from '@home/components/widget/widget.component'; import { WidgetComponent } from '@home/components/widget/widget.component';
@ -44,16 +46,20 @@ import {
ValueChartCardLayout, ValueChartCardLayout,
ValueChartCardWidgetSettings ValueChartCardWidgetSettings
} from '@home/components/widget/lib/cards/value-chart-card-widget.models'; } 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 { 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 { getTsValueByLatestDataKey } from '@home/components/widget/lib/cards/aggregated-value-card.models';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ImagePipe } from '@shared/pipe/image.pipe'; import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser'; 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 layoutHeight = 56;
const valueRelativeMaxWidth = 0.5; const valueRelativeWidth = 0.35;
@Component({ @Component({
selector: 'tb-value-chart-card-widget', selector: 'tb-value-chart-card-widget',
@ -81,6 +87,7 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
widgetTitlePanel: TemplateRef<any>; widgetTitlePanel: TemplateRef<any>;
layout: ValueChartCardLayout; layout: ValueChartCardLayout;
autoScale: boolean;
showValue = true; showValue = true;
valueText = 'N/A'; valueText = 'N/A';
@ -90,8 +97,8 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
backgroundStyle$: Observable<ComponentStyle>; backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {}; overlayStyle: ComponentStyle = {};
private flot: TbFlot; private lineChart: TbTimeSeriesChart;
private flotDataKey: DataKey; private lineChartDataKey: DataKey;
private valueKey: DataKey; private valueKey: DataKey;
private contentResize$: ResizeObserver; private contentResize$: ResizeObserver;
@ -129,6 +136,7 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
} }
this.layout = this.settings.layout; this.layout = this.settings.layout;
this.autoScale = this.settings.autoScale;
this.showValue = this.settings.showValue; this.showValue = this.settings.showValue;
this.valueStyle = textStyle(this.settings.valueFont); this.valueStyle = textStyle(this.settings.valueFont);
@ -138,37 +146,34 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
this.overlayStyle = overlayStyle(this.settings.background.overlay); this.overlayStyle = overlayStyle(this.settings.background.overlay);
if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) { if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) {
this.flotDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0]; this.lineChartDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0];
this.flotDataKey.settings = { this.lineChartDataKey.settings = {
fillLines: false, showPointLabel: false,
showLines: true, type: TimeSeriesChartSeriesType.line,
lineWidth: 2 lineSettings: {
} as TbFlotKeySettings; smooth: true,
showLine: true,
lineWidth: 2,
showPoints: false
}
} as TimeSeriesChartKeySettings;
} }
} }
public ngAfterViewInit() { public ngAfterViewInit() {
const settings = { const settings: TimeSeriesChartSettings = {
shadowSize: 0, dataZoom: false,
enableSelection: false, xAxis: {
smoothLines: true, show: false
grid: {
tickColor: 'rgba(0,0,0,0.12)',
horizontalLines: false,
verticalLines: false,
outlineWidth: 0,
minBorderMargin: 0,
margin: 0
}, },
yaxis: { yAxis: {
showLabels: false, show: false
tickGenerator: 'return [(axis.max + axis.min) / 2];'
}, },
xaxis: { tooltipDateInterval: false,
showLabels: false tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm:ss')
} } as TimeSeriesChartSettings;
} as TbFlotSettings;
this.flot = new TbFlot(this.ctx, 'line', $(this.chartElement.nativeElement), settings); this.lineChart = new TbTimeSeriesChart(this.ctx, settings, this.chartElement.nativeElement, this.renderer, false);
this.contentResize$ = new ResizeObserver(() => { this.contentResize$ = new ResizeObserver(() => {
this.onResize(); this.onResize();
@ -190,7 +195,9 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
} }
public onDataUpdated() { public onDataUpdated() {
this.flot.update(); if (this.lineChart) {
this.lineChart.update();
}
} }
public onLatestDataUpdated() { public onLatestDataUpdated() {
@ -206,20 +213,21 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
this.valueColor.update(value); this.valueColor.update(value);
this.cd.detectChanges(); this.cd.detectChanges();
setTimeout(() => { setTimeout(() => {
this.onResize(false); this.onResize();
}, 0); }, 0);
} }
} }
public onEditModeChanged() { public onEditModeChanged() {
this.flot.checkMouseEvents();
} }
public onDestroy() { 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) { if (this.settings.autoScale && this.showValue) {
const contentWidth = this.valueChartCardContent.nativeElement.getBoundingClientRect().width; const contentWidth = this.valueChartCardContent.nativeElement.getBoundingClientRect().width;
const contentHeight = this.valueChartCardContent.nativeElement.getBoundingClientRect().height; const contentHeight = this.valueChartCardContent.nativeElement.getBoundingClientRect().height;
@ -228,26 +236,26 @@ export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnD
this.valueFontSize = resolveCssSize(fontSize)[0]; this.valueFontSize = resolveCssSize(fontSize)[0];
} }
const valueRelativeHeight = Math.min(this.valueFontSize / layoutHeight, 1); const valueRelativeHeight = Math.min(this.valueFontSize / layoutHeight, 1);
const targetValueHeight = contentHeight * valueRelativeHeight; const targetValueWidth = contentWidth * valueRelativeWidth;
const maxValueWidth = contentWidth * valueRelativeMaxWidth; const maxValueHeight = contentHeight * valueRelativeHeight;
this.setValueFontSize(targetValueHeight, maxValueWidth, fitTargetHeight); 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; const fontSize = getComputedStyle(this.valueChartCardValue.nativeElement).fontSize;
let valueFontSize = resolveCssSize(fontSize)[0]; let valueFontSize = resolveCssSize(fontSize)[0];
this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'fontSize', valueFontSize + 'px'); this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'fontSize', valueFontSize + 'px');
this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'lineHeight', '1'); this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'lineHeight', '1');
let valueHeight = this.valueChartCardValue.nativeElement.getBoundingClientRect().height; let valueWidth = this.valueChartCardValue.nativeElement.getBoundingClientRect().width;
while (fitTargetHeight && valueHeight < targetHeight) { while (fitTargetWidth && valueWidth < targetWidth) {
valueFontSize++; valueFontSize++;
this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'fontSize', valueFontSize + 'px'); 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; let valueHeight = this.valueChartCardValue.nativeElement.getBoundingClientRect().height;
while ((valueHeight > targetHeight || valueWidth > maxWidth) && valueFontSize > 6) { while ((valueWidth > targetWidth || valueHeight > maxHeight) && valueFontSize > 6) {
valueFontSize--; valueFontSize--;
this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'fontSize', valueFontSize + 'px'); this.renderer.setStyle(this.valueChartCardValue.nativeElement, 'fontSize', valueFontSize + 'px');
valueWidth = this.valueChartCardValue.nativeElement.getBoundingClientRect().width; valueWidth = this.valueChartCardValue.nativeElement.getBoundingClientRect().width;

9
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 { import {
ECharts, ECharts,
echartsModule, echartsModule,
EChartsOption, EChartsOption, EChartsSeriesItem,
echartsTooltipFormatter, echartsTooltipFormatter,
NamedDataSet, NamedDataSet,
toNamedData toNamedData
} from '@home/components/widget/lib/chart/echarts-widget.models'; } from '@home/components/widget/lib/chart/echarts-widget.models';
import { IntervalMath } from '@shared/models/time/time.models'; import { IntervalMath } from '@shared/models/time/time.models';
interface BarChartDataItem { type BarChartDataItem = EChartsSeriesItem;
id: string;
dataKey: DataKey;
data: NamedDataSet;
enabled: boolean;
}
interface BarChartLegendItem { interface BarChartLegendItem {
id: string; id: string;

1
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)', tooltipValueColor: 'rgba(0, 0, 0, 0.76)',
tooltipShowDate: true, tooltipShowDate: true,
tooltipDateInterval: true,
tooltipDateFormat: customDateFormat('MMMM y'), tooltipDateFormat: customDateFormat('MMMM y'),
tooltipDateFont: { tooltipDateFont: {
family: 'Roboto', family: 'Roboto',

204
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 * as echarts from 'echarts/core';
import { Axis } from 'echarts'; import { Axis } from 'echarts';
import AxisModel from 'echarts/types/src/coord/cartesian/AxisModel'; 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 TimeScale from 'echarts/types/src/scale/Time';
import { import {
DataZoomComponent, DataZoomComponentOption, DataZoomComponent,
GridComponent, GridComponentOption, DataZoomComponentOption,
MarkLineComponent, MarkLineComponentOption, GridComponent,
TooltipComponent, TooltipComponentOption, GridComponentOption,
VisualMapComponent, VisualMapComponentOption MarkLineComponent,
MarkLineComponentOption,
TooltipComponent,
TooltipComponentOption,
VisualMapComponent,
VisualMapComponentOption
} from 'echarts/components'; } from 'echarts/components';
import { import {
BarChart, BarChart,
LineChart, BarSeriesOption,
CustomChart, CustomChart,
CustomSeriesOption, CustomSeriesOption,
LineChart,
LineSeriesOption, LineSeriesOption,
BarSeriesOption, PieSeriesOption, PieChart PieChart,
PieSeriesOption
} from 'echarts/charts'; } from 'echarts/charts';
import { LabelLayout } from 'echarts/features'; import { LabelLayout } from 'echarts/features';
import { CanvasRenderer, SVGRenderer } from 'echarts/renderers'; import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
import { DataEntry, DataSet } from '@shared/models/widget.models'; import { DataEntry, DataKey, DataSet } from '@shared/models/widget.models';
import { import {
calculateAggIntervalWithWidgetTimeWindow, calculateAggIntervalWithWidgetTimeWindow,
Interval,
IntervalMath, IntervalMath,
WidgetTimewindow WidgetTimewindow
} from '@shared/models/time/time.models'; } from '@shared/models/time/time.models';
import { CallbackDataParams } from 'echarts/types/dist/shared'; import { CallbackDataParams } from 'echarts/types/dist/shared';
import { Renderer2 } from '@angular/core'; import { Renderer2 } from '@angular/core';
import { DateFormatProcessor, DateFormatSettings, Font } from '@shared/models/widget-settings.models'; 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 { class EChartsModule {
private initialized = false; private initialized = false;
@ -139,6 +149,133 @@ export type EChartsDataItem = [number, any, number, number];
export type NamedDataSet = {name: string; value: EChartsDataItem}[]; 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<LineSeriesOption>;
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 => { export const toNamedData = (data: DataSet): NamedDataSet => {
if (!data?.length) { if (!data?.length) {
return []; return [];
@ -162,11 +299,18 @@ const toEChartsDataItem = (entry: DataEntry): EChartsDataItem => {
return item; return item;
}; };
export enum EChartsTooltipTrigger {
point = 'point',
axis = 'axis'
}
export interface EChartsTooltipWidgetSettings { export interface EChartsTooltipWidgetSettings {
showTooltip: boolean; showTooltip: boolean;
tooltipTrigger?: EChartsTooltipTrigger;
tooltipValueFont: Font; tooltipValueFont: Font;
tooltipValueColor: string; tooltipValueColor: string;
tooltipShowDate: boolean; tooltipShowDate: boolean;
tooltipDateInterval?: boolean;
tooltipDateFormat: DateFormatSettings; tooltipDateFormat: DateFormatSettings;
tooltipDateFont: Font; tooltipDateFont: Font;
tooltipDateColor: string; tooltipDateColor: string;
@ -177,13 +321,15 @@ export interface EChartsTooltipWidgetSettings {
export const echartsTooltipFormatter = (renderer: Renderer2, export const echartsTooltipFormatter = (renderer: Renderer2,
tooltipDateFormat: DateFormatProcessor, tooltipDateFormat: DateFormatProcessor,
settings: EChartsTooltipWidgetSettings, settings: EChartsTooltipWidgetSettings,
params: CallbackDataParams[], params: CallbackDataParams[] | CallbackDataParams,
decimals: number, decimals: number,
units: string, units: string,
focusedSeriesIndex: number): null | HTMLElement => { focusedSeriesIndex: number,
if (!params.length || !params[0]) { series?: EChartsSeriesItem[]): null | HTMLElement => {
if (!params || Array.isArray(params) && !params[0]) {
return null; return null;
} }
const firstParam = Array.isArray(params) ? params[0] : params;
const tooltipElement: HTMLElement = renderer.createElement('div'); const tooltipElement: HTMLElement = renderer.createElement('div');
renderer.setStyle(tooltipElement, 'display', 'flex'); renderer.setStyle(tooltipElement, 'display', 'flex');
renderer.setStyle(tooltipElement, 'flex-direction', 'column'); renderer.setStyle(tooltipElement, 'flex-direction', 'column');
@ -192,9 +338,9 @@ export const echartsTooltipFormatter = (renderer: Renderer2,
if (settings.tooltipShowDate) { if (settings.tooltipShowDate) {
const dateElement: HTMLElement = renderer.createElement('div'); const dateElement: HTMLElement = renderer.createElement('div');
let dateText: string; let dateText: string;
const startTs = params[0].value[2]; const startTs = firstParam.value[2];
const endTs = params[0].value[3]; const endTs = firstParam.value[3];
if (startTs && endTs && (endTs - 1) > startTs) { if (settings.tooltipDateInterval && startTs && endTs && (endTs - 1) > startTs) {
const startDateText = tooltipDateFormat.update(startTs); const startDateText = tooltipDateFormat.update(startTs);
const endDateText = tooltipDateFormat.update(endTs - 1); const endDateText = tooltipDateFormat.update(endTs - 1);
if (startDateText === endDateText) { if (startDateText === endDateText) {
@ -203,7 +349,7 @@ export const echartsTooltipFormatter = (renderer: Renderer2,
dateText = startDateText + ' - ' + endDateText; dateText = startDateText + ' - ' + endDateText;
} }
} else { } else {
const ts = params[0].value[0]; const ts = firstParam.value[0];
dateText = tooltipDateFormat.update(ts); dateText = tooltipDateFormat.update(ts);
} }
renderer.appendChild(dateElement, renderer.createText(dateText)); renderer.appendChild(dateElement, renderer.createText(dateText));
@ -216,14 +362,16 @@ export const echartsTooltipFormatter = (renderer: Renderer2,
renderer.appendChild(tooltipElement, dateElement); renderer.appendChild(tooltipElement, dateElement);
} }
let seriesParams: CallbackDataParams = null; let seriesParams: CallbackDataParams = null;
if (focusedSeriesIndex > -1) { if (Array.isArray(params) && focusedSeriesIndex > -1) {
seriesParams = params.find(param => param.seriesIndex === focusedSeriesIndex); seriesParams = params.find(param => param.seriesIndex === focusedSeriesIndex);
} else if (!Array.isArray(params)) {
seriesParams = params;
} }
if (seriesParams) { if (seriesParams) {
renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units)); renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units, series));
} else { } else if (Array.isArray(params)) {
for (seriesParams of 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; return tooltipElement;
@ -233,7 +381,8 @@ const constructEchartsTooltipSeriesElement = (renderer: Renderer2,
settings: EChartsTooltipWidgetSettings, settings: EChartsTooltipWidgetSettings,
seriesParams: CallbackDataParams, seriesParams: CallbackDataParams,
decimals: number, decimals: number,
units: string): HTMLElement => { units: string,
series?: EChartsSeriesItem[]): HTMLElement => {
const labelValueElement: HTMLElement = renderer.createElement('div'); const labelValueElement: HTMLElement = renderer.createElement('div');
renderer.setStyle(labelValueElement, 'display', 'flex'); renderer.setStyle(labelValueElement, 'display', 'flex');
renderer.setStyle(labelValueElement, 'flex-direction', 'row'); 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.setStyle(labelTextElement, 'color', 'rgba(0, 0, 0, 0.76)');
renderer.appendChild(labelElement, labelTextElement); renderer.appendChild(labelElement, labelTextElement);
const valueElement: HTMLElement = renderer.createElement('div'); 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.appendChild(valueElement, renderer.createText(value));
renderer.setStyle(valueElement, 'flex', '1'); renderer.setStyle(valueElement, 'flex', '1');
renderer.setStyle(valueElement, 'text-align', 'end'); renderer.setStyle(valueElement, 'text-align', 'end');

1
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)', tooltipValueColor: 'rgba(0, 0, 0, 0.76)',
tooltipShowDate: true, tooltipShowDate: true,
tooltipDateInterval: true,
tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm'), tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm'),
tooltipDateFont: { tooltipDateFont: {
family: 'Roboto', family: 'Roboto',

134
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] }
}
};
};

122
ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html

@ -0,0 +1,122 @@
<!--
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.
-->
<div class="tb-time-series-chart-panel" [style]="backgroundStyle$ | async">
<div class="tb-time-series-chart-overlay" [style]="overlayStyle"></div>
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
<div class="tb-time-series-chart-content" [class]="legendClass">
<div #chartShape class="tb-time-series-chart-shape">
</div>
<div *ngIf="showLegend" class="tb-time-series-chart-legend" [class.tb-simple-legend]="!displayLegendValues">
<ng-container *ngIf="displayLegendValues; else simpleLegend">
<ng-container *ngIf="horizontalLegendPosition; else verticalLegend">
<ng-container *ngTemplateOutlet="horizontalLegend"></ng-container>
</ng-container>
</ng-container>
</div>
<ng-template #horizontalLegend>
<table class="tb-time-series-chart-legend-table">
<thead>
<tr>
<th></th>
<th *ngFor="let legendKey of legendKeys">
<ng-container *ngTemplateOutlet="legendItem; context:{legendKey: legendKey}"></ng-container>
</th>
</tr>
</thead>
<tbody>
<ng-container *ngIf="legendConfig.showMin === true">
<ng-container *ngTemplateOutlet="legendDataRow; context:{label: 'legend.Min', type: 'min'}"></ng-container>
</ng-container>
<ng-container *ngIf="legendConfig.showMax === true">
<ng-container *ngTemplateOutlet="legendDataRow; context:{label: 'legend.Max', type: 'max'}"></ng-container>
</ng-container>
<ng-container *ngIf="legendConfig.showAvg === true">
<ng-container *ngTemplateOutlet="legendDataRow; context:{label: 'legend.Avg', type: 'avg'}"></ng-container>
</ng-container>
<ng-container *ngIf="legendConfig.showTotal === true">
<ng-container *ngTemplateOutlet="legendDataRow; context:{label: 'legend.Total', type: 'total'}"></ng-container>
</ng-container>
<ng-container *ngIf="legendConfig.showLatest === true">
<ng-container *ngTemplateOutlet="legendDataRow; context:{label: 'legend.Latest', type: 'latest'}"></ng-container>
</ng-container>
</tbody>
</table>
</ng-template>
<ng-template #verticalLegend>
<table class="tb-time-series-chart-legend-table vertical">
<thead>
<tr>
<th></th>
<th class="tb-time-series-chart-legend-type-label right" *ngIf="legendConfig.showMin === true">{{ 'legend.Min' | translate }}</th>
<th class="tb-time-series-chart-legend-type-label right" *ngIf="legendConfig.showMax === true">{{ 'legend.Max' | translate }}</th>
<th class="tb-time-series-chart-legend-type-label right" *ngIf="legendConfig.showAvg === true">{{ 'legend.Avg' | translate }}</th>
<th class="tb-time-series-chart-legend-type-label right" *ngIf="legendConfig.showTotal === true">{{ 'legend.Total' | translate }}</th>
<th class="tb-time-series-chart-legend-type-label right" *ngIf="legendConfig.showLatest === true">{{ 'legend.Latest' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let legendKey of legendKeys">
<th>
<ng-container *ngTemplateOutlet="legendItem; context:{legendKey: legendKey, left: true}"></ng-container>
</th>
<td *ngIf="legendConfig.showMin === true" class="tb-time-series-chart-legend-value">
{{ legendData.data[legendKey.dataIndex].min }}
</td>
<td *ngIf="legendConfig.showMax === true" class="tb-time-series-chart-legend-value">
{{ legendData.data[legendKey.dataIndex].max }}
</td>
<td *ngIf="legendConfig.showAvg === true" class="tb-time-series-chart-legend-value">
{{ legendData.data[legendKey.dataIndex].avg }}
</td>
<td *ngIf="legendConfig.showTotal === true" class="tb-time-series-chart-legend-value">
{{ legendData.data[legendKey.dataIndex].total }}
</td>
<td *ngIf="legendConfig.showLatest === true" class="tb-time-series-chart-legend-value">
{{ legendData.data[legendKey.dataIndex].latest }}
</td>
</tr>
</tbody>
</table>
</ng-template>
<ng-template #simpleLegend>
<ng-container *ngFor="let legendKey of legendKeys">
<ng-container *ngTemplateOutlet="legendItem; context:{legendKey: legendKey}"></ng-container>
</ng-container>
</ng-template>
<ng-template #legendItem let-legendKey="legendKey" let-left="left">
<div class="tb-time-series-chart-legend-item"
[class.left]="left">
<div class="tb-time-series-chart-legend-item-label"
(mouseenter)="onLegendKeyEnter(legendKey)"
(mouseleave)="onLegendKeyLeave(legendKey)"
(click)="toggleLegendKey(legendKey)">
<div class="tb-time-series-chart-legend-item-label-circle" [style]="{background: !legendKey.dataKey.hidden ? legendKey.dataKey.color : null}"></div>
<div [style]="!legendKey.dataKey.hidden ? legendLabelStyle : disabledLegendLabelStyle">{{ legendKey.dataKey.label }}</div>
</div>
</div>
</ng-template>
<ng-template #legendDataRow let-label="label" let-type="type">
<tr>
<th class="tb-time-series-chart-legend-type-label">{{ label | translate }}</th>
<td *ngFor="let legendKey of legendKeys" class="tb-time-series-chart-legend-value">
{{ legendData.data[legendKey.dataIndex][type] }}
</td>
</tr>
</ng-template>
</div>
</div>

181
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;
}
}
}
}

163
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<HTMLElement>;
settings: TimeSeriesChartWidgetSettings;
@Input()
ctx: WidgetContext;
@Input()
widgetTitlePanel: TemplateRef<any>;
horizontalLegendPosition = false;
showLegend: boolean;
legendClass: string;
legendConfig: LegendConfig;
legendData: LegendData;
legendKeys: LegendKey[];
backgroundStyle$: Observable<ComponentStyle>;
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<TimeSeriesChartKeySettings>({} 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);
}
}

52
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);

873
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<LineSeriesOption | CustomSeriesOption> => {
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<LineSeriesOption> => {
const series: Array<LineSeriesOption> = [];
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<LineSeriesOption | CustomSeriesOption> => {
const series: Array<LineSeriesOption | CustomSeriesOption> = [];
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;
};

642
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<TimeSeriesChartKeySettings>({} 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<LineSeriesOption | CustomSeriesOption> {
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();
}
}
}
}
}

7
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 { PowerButtonWidgetComponent } from '@home/components/widget/lib/rpc/power-button-widget.component';
import { SliderWidgetComponent } from '@home/components/widget/lib/rpc/slider-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 { 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({ @NgModule({
declarations: declarations:
@ -124,7 +125,8 @@ import { ToggleButtonWidgetComponent } from '@home/components/widget/lib/button/
CommandButtonWidgetComponent, CommandButtonWidgetComponent,
PowerButtonWidgetComponent, PowerButtonWidgetComponent,
SliderWidgetComponent, SliderWidgetComponent,
ToggleButtonWidgetComponent ToggleButtonWidgetComponent,
TimeSeriesChartWidgetComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -177,7 +179,8 @@ import { ToggleButtonWidgetComponent } from '@home/components/widget/lib/button/
CommandButtonWidgetComponent, CommandButtonWidgetComponent,
PowerButtonWidgetComponent, PowerButtonWidgetComponent,
SliderWidgetComponent, SliderWidgetComponent,
ToggleButtonWidgetComponent ToggleButtonWidgetComponent,
TimeSeriesChartWidgetComponent
], ],
providers: [ providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule }

24
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;
}

5
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3169,6 +3169,11 @@
"avg": "avg", "avg": "avg",
"total": "total", "total": "total",
"latest": "latest", "latest": "latest",
"Min": "Min",
"Max": "Max",
"Avg": "Avg",
"Total": "Total",
"Latest": "Latest",
"comparison-time-ago": { "comparison-time-ago": {
"previousInterval": "(previous interval)", "previousInterval": "(previous interval)",
"customInterval": "(custom interval)", "customInterval": "(custom interval)",

Loading…
Cancel
Save