Merge pull request #10535 from thingsboard/feature/state-chart

State chart widget
This commit is contained in:
Igor Kulikov 2024-04-10 18:34:13 +03:00 committed by GitHub
commit 18178b5243
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1056 additions and 83 deletions

View File

@ -12,10 +12,11 @@
"line_chart",
"bar_chart",
"point_chart",
"state_chart",
"bar_chart_with_labels",
"range_chart",
"charts.basic_timeseries",
"charts.state_chart",
"range_chart",
"bar_chart_with_labels",
"charts.timeseries_bars_flot",
"cards.aggregated_value_card",
"charts.bars",

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,25 @@
{
"fqn": "charts.state_chart",
"name": "State Chart",
"deprecated": true,
"image": "tb-image:c3RhdGVfY2hhcnRfc3lzdGVtX3dpZGdldF9pbWFnZS5wbmc=:IlN0YXRlIENoYXJ0IiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAB9VBMVEUAAAAhlvMilvMymeE+nNVDoetInslNq/VZqOdpuPd3d3d5p5Z5suB6enp8fHyBgYGDg4ODqoqEhISIiIiKioqKuN2MjIyNjY2Ojo6QkJCRkZGSkpKUlJSVlZWWlpaXl5eYmJiZmZmampqcnJydnZ2enp6goKCgr3KhoaGioqKisXSjo6OjvsmkpKSlpaWlwdempqanp6eoqKiosGOo1vqpqampsGOqqqqqsWOrq6usrKysw9Wurq6wsLCysrK0tLS1tbW1wbK2tra3t7e4wau5ubm6urq7u7u8vLy9vb2+vr6/xbTAwMDAxbjCwsLC3ejDw8PExMTFxcXGxsbHx8fIv3jIyMjJycnKysrK5vzMzc7Nzc3Ozs7Pz8/QuDnRzcHS0tLT09PT39HU1NTU6/3VzLLV1dXV3sjW1tbX19fYuTHY2NjZ2dna0Ira2trb29vc3Nzex4De3t7f39/guyvhvCfhvCvh4eHh6Nbi4uLjvCTj4+PkyXbk5OTlvCTm5ubm693o6Ojpxl7q6urr6+vswTTs7Ozt7e3uvhju7u7vvhjv7+/wvxjw8PDx8fHyvxX0yTv09PT19fX29vb39/f4wyL4+Pj5+fn6+vr75J37+/v8whP8/Pz9/f39/v/+/v7/wQf/xRb/3HT/5JH/9tz/++/////APs7XAAAAAWJLR0Smt7AblQAAA1RJREFUeNrt3dlT01AUBvAEd8UNl2oLrdrFolZArUul1hWlVhQXFAUF1xaxIqi4FUQUrDsUClZi4nb+Th96S9NQkjDOOKZ+3wuZw8md++M25OXMlKOccJQnTUYocdRqdw8UAqTfOjG4lvr7uowOqbtAtG6o5OiGFoNDapuJHO8tFK4xOKS9msTVgoUiaQjPclnSl01snZ/y4ly2yBNZbRarbc+3yvdpt/hLawOPJp9ur7O0mRiEmzFkY1M6N/4I8qJpulzR2sBx1sgRjQuyA2pk+STdylw2VqR/FPFfWdcCvjhduiM7kVF2db1Nuspu7JGes+I76SRba7f0Wfnn/6F6It+mfo7m8TvYvh7KTiT3PQIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggPwXkFa7e7AQIP3WiUFzdl7LuBDFvJZxIYp5LeNCFPNaBn7Yc+e1KlheSYcrFCniL7LZqPl8cbp0TjavNcqu9rZJB9gdPdIJVnwjbWG1ndI95UzWM9V5rS9Ti3P4VWy1+5jXAgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAMK+FeS3Ma2FeC/NamNfCCxEQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBA/hZkTKSkwSGn7VUJOmOLV241NiRSRZ2uYcvt+Io+Y0N83USm+MqGWEnU2JCqXqI1ZE+QhYiIy36tXR5INpOQbB5kftcmK85mtdey2iJekeWqX6/3kc+TLCQTrq6eEuZCgKTsbnNXBsIZOERJMedNQnry73XpW8UAKVhIyBPScVfQE9RqEaP7iMT6g+pdw7vKbxKl3IJqV8K7OUqXPHvG9UMGykk2lz1d4g5yvNXo6QqZiA41aHT5OwQTUWBJSrWrvlMwj1jFU2f1Q8K1dCysCUmVhdenNLvMRKZt3hGNriEnxfxlGqt9aPYRkb9bP6Q1SMFrmltMukKuMT2QpckWjc/WhP2lYB/TgjzdHyCKVM/gGXnsp+qY5hbba+hIVA/ETL0+9SepsoNiTs/igGpXZIhKqVv9QVJARIfXIWqfiM1nS+qBnHdae1V7Qss8ngEijRO5a6sMCAtdqv+HfgPwpNPbU6ipOwAAAABJRU5ErkJggg==",
"description": "Displays changes to the state of the entity over time. For example, online and offline.",
"descriptor": {
"type": "timeseries",
"sizeX": 8,
"sizeY": 5,
"resources": [],
"templateHtml": "<tb-flot-widget \n [ctx]=\"ctx\" chartType=\"state\">\n</tb-flot-widget>",
"templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n",
"controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.flotWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.flotWidget.onLatestDataUpdated();\n}\n\nself.onResize = function() {\n self.ctx.$scope.flotWidget.onResize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.flotWidget.onEditModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.$scope.flotWidget.onDestroy();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true,\n hasAdditionalLatestDataKeys: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries', units: '°C', decimals: 0 }];\n }\n };\n}\n\n",
"settingsSchema": "{}",
"dataKeySettingsSchema": "{}",
"settingsDirective": "tb-flot-line-widget-settings",
"dataKeySettingsDirective": "tb-flot-line-key-settings",
"latestDataKeySettingsDirective": "tb-flot-latest-key-settings",
"hasBasicMode": true,
"basicModeDirective": "tb-flot-basic-config",
"defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 1\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false,\"axisPosition\":\"left\",\"showSeparateAxis\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"return Math.random() > 0.5 ? 1 : 0;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 2\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false,\"axisPosition\":\"left\"},\"_hash\":0.12775350966079668,\"funcBody\":\"return Math.random() <= 0.5 ? 1 : 0;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"stack\":false,\"fontSize\":10,\"fontColor\":\"#545454\",\"showTooltip\":true,\"tooltipIndividual\":false,\"tooltipCumulative\":false,\"hideZeros\":false,\"tooltipValueFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\",\"grid\":{\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1,\"color\":\"#545454\",\"backgroundColor\":null,\"tickColor\":\"#DDDDDD\"},\"xaxis\":{\"title\":null,\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"min\":0,\"max\":1.2,\"title\":null,\"showLabels\":true,\"color\":\"#545454\",\"tickSize\":null,\"tickDecimals\":0,\"ticksFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\"},\"shadowSize\":4,\"smoothLines\":false,\"comparisonEnabled\":false,\"timeForComparison\":\"previousInterval\",\"comparisonCustomIntervalValue\":7200000,\"xaxisSecond\":{\"axisPosition\":\"top\",\"title\":null,\"showLabels\":true},\"showLegend\":true,\"legendConfig\":{\"direction\":\"column\",\"position\":\"right\",\"sortDataKeys\":false,\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false,\"showLatest\":false},\"customLegendEnabled\":false,\"dataKeysListForLabels\":[]},\"title\":\"State Chart\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":true,\"actions\":{},\"configMode\":\"basic\",\"showTitleIcon\":false,\"titleIcon\":\"waterfall_chart\",\"iconColor\":\"#1F6BDD\"}"
},
"tags": null
}

View File

@ -378,6 +378,7 @@ export class DataAggregator {
private updateStateBounds(keyData: DataSet, lastPrevKvPair: DataEntry) {
if (lastPrevKvPair) {
lastPrevKvPair[0] = this.startTs;
lastPrevKvPair[2] = [this.startTs, this.startTs];
}
let firstKvPair: DataEntry;
if (!keyData.length) {
@ -398,6 +399,7 @@ export class DataAggregator {
if (lastKvPair[0] < this.endTs) {
lastKvPair = deepClone(lastKvPair);
lastKvPair[0] = this.endTs;
lastKvPair[2] = [this.endTs, this.endTs];
keyData.push(lastKvPair);
}
}

View File

@ -30,7 +30,7 @@ import {
import { TimeService } from '../services/time.service';
import { DeviceService } from '../http/device.service';
import { UtilsService } from '@core/services/utils.service';
import { Timewindow, WidgetTimewindow } from '@shared/models/time/time.models';
import { SubscriptionTimewindow, Timewindow, WidgetTimewindow } from '@shared/models/time/time.models';
import { EntityType } from '@shared/models/entity-type.models';
import { HttpErrorResponse } from '@angular/common/http';
import { RafService } from '@core/services/raf.service';
@ -303,6 +303,7 @@ export interface IWidgetSubscription {
hiddenData?: Array<{data: DataSet}>;
timeWindowConfig?: Timewindow;
timeWindow?: WidgetTimewindow;
subscriptionTimewindow: SubscriptionTimewindow;
onTimewindowChangeFunction?: (timewindow: Timewindow) => Timewindow;
widgetTimewindowChanged$: Observable<WidgetTimewindow>;
comparisonEnabled?: boolean;

View File

@ -41,6 +41,10 @@
[entityAliasId]="datasource?.entityAliasId"
formControlName="series">
</tb-data-keys-panel>
<tb-time-series-chart-states-panel
*ngIf="chartType === TimeSeriesChartType.state"
formControlName="states">
</tb-time-series-chart-states-panel>
<tb-time-series-chart-y-axes-panel
formControlName="yAxes"
(axisRemoved)="yAxisRemoved($event)">

View File

@ -46,8 +46,9 @@ import {
} from '@home/components/widget/lib/chart/time-series-chart-widget.models';
import { EChartsTooltipTrigger } from '@home/components/widget/lib/chart/echarts-widget.models';
import {
TimeSeriesChartKeySettings, TimeSeriesChartThreshold,
TimeSeriesChartType, TimeSeriesChartYAxes,
TimeSeriesChartKeySettings,
TimeSeriesChartType,
TimeSeriesChartYAxes,
TimeSeriesChartYAxisId
} from '@home/components/widget/lib/chart/time-series-chart.models';
@ -178,6 +179,9 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon
actions: [configData.config.actions || {}, []]
});
if (this.chartType === TimeSeriesChartType.state) {
this.timeSeriesChartWidgetConfigForm.addControl('states', this.fb.control(settings.states, []));
}
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
@ -233,6 +237,10 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon
this.widgetConfig.config.settings.padding = config.padding;
this.widgetConfig.config.actions = config.actions;
if (this.chartType === TimeSeriesChartType.state) {
this.widgetConfig.config.settings.states = config.states;
}
return this.widgetConfig;
}

View File

@ -17,7 +17,7 @@
import * as echarts from 'echarts/core';
import AxisModel from 'echarts/types/src/coord/cartesian/AxisModel';
import { estimateLabelUnionRect } from 'echarts/lib/coord/axisHelper';
import { formatValue, isDefinedAndNotNull, isNumber } from '@core/utils';
import { isDefinedAndNotNull, isFunction, isNumber } from '@core/utils';
import {
DataZoomComponent,
DataZoomComponentOption,
@ -42,7 +42,7 @@ import {
} from 'echarts/charts';
import { LabelLayout } from 'echarts/features';
import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
import { DataEntry, DataKey, DataSet } from '@shared/models/widget.models';
import { DataEntry, DataKey, DataSet, Datasource, FormattedData } from '@shared/models/widget.models';
import {
calculateAggIntervalWithWidgetTimeWindow,
Interval,
@ -56,6 +56,7 @@ 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';
import { MarkLine2DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel';
import { TimeAxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes';
class EChartsModule {
private initialized = false;
@ -101,14 +102,19 @@ export type EChartsDataItem = [number, any, number, number];
export type NamedDataSet = {name: string; value: EChartsDataItem}[];
export type EChartsTooltipValueFormatFunction = (value: any, latestData: FormattedData, units?: string, decimals?: number) => string;
export type EChartsSeriesItem = {
id: string;
datasource: Datasource;
dataKey: DataKey;
data: NamedDataSet;
dataSet?: DataSet;
enabled: boolean;
units?: string;
decimals?: number;
latestData?: FormattedData;
tooltipValueFormatFunction?: EChartsTooltipValueFormatFunction;
};
export enum EChartsShape {
@ -270,7 +276,7 @@ const measureSymbolOffset = (symbol: string, symbolSize: any): number => {
} else {
return 0;
}
}
};
export const measureThresholdOffset = (chart: ECharts, axisId: string, thresholdId: string, value: any): [number, number] => {
const offset: [number, number] = [0,0];
@ -395,7 +401,7 @@ export const getFocusedSeriesIndex = (chart: ECharts): number => {
return -1;
};
export const toNamedData = (data: DataSet): NamedDataSet => {
export const toNamedData = (data: DataSet, valueConverter?: (value: any) => any): NamedDataSet => {
if (!data?.length) {
return [];
} else {
@ -403,14 +409,43 @@ export const toNamedData = (data: DataSet): NamedDataSet => {
const ts = isDefinedAndNotNull(d[2]) ? d[2][0] : d[0];
return {
name: ts + '',
value: toEChartsDataItem(d)
value: toEChartsDataItem(d, valueConverter)
};
});
}
};
const toEChartsDataItem = (entry: DataEntry): EChartsDataItem => {
const item: EChartsDataItem = [entry[0], entry[1], entry[0], entry[0]];
const minDataTs = (dataSet: NamedDataSet): number => dataSet.length ? dataSet.map(data =>
Number(data.name)).reduce((a, b) => Math.min(a, b)) : undefined;
const maxDataTs = (dataSet: NamedDataSet): number => dataSet.length ? dataSet.map(data =>
Number(data.name)).reduce((a, b) => Math.max(a, b)) : undefined;
export const adjustTimeAxisExtentToData = (timeAxisOption: TimeAxisBaseOption,
dataItems: EChartsSeriesItem[],
defaultMin: number,
defaultMax: number): void => {
let min: number;
let max: number;
for (const item of dataItems) {
if (item.enabled) {
const minTs = minDataTs(item.data);
if (typeof minTs !== 'undefined') {
min = (typeof min !== 'undefined') ? Math.min(min, minTs) : minTs;
}
const maxTs = maxDataTs(item.data);
if (typeof maxTs !== 'undefined') {
max = (typeof max !== 'undefined') ? Math.max(max, maxTs) : maxTs;
}
}
}
timeAxisOption.min = (typeof min !== 'undefined') ? min : defaultMin;
timeAxisOption.max = (typeof max !== 'undefined') ? max : defaultMax;
};
const toEChartsDataItem = (entry: DataEntry, valueConverter?: (value: any) => any): EChartsDataItem => {
const value = valueConverter ? valueConverter(entry[1]) : entry[1];
const item: EChartsDataItem = [entry[0], value, entry[0], entry[0]];
if (isDefinedAndNotNull(entry[2])) {
item[2] = entry[2][0];
item[3] = entry[2][1];
@ -436,6 +471,7 @@ export interface EChartsTooltipWidgetSettings {
tooltipShowFocusedSeries?: boolean;
tooltipValueFont: Font;
tooltipValueColor: string;
tooltipValueFormatter?: string | EChartsTooltipValueFormatFunction;
tooltipShowDate: boolean;
tooltipDateInterval?: boolean;
tooltipDateFormat: DateFormatSettings;
@ -445,12 +481,25 @@ export interface EChartsTooltipWidgetSettings {
tooltipBackgroundBlur: number;
}
export const createTooltipValueFormatFunction =
(tooltipValueFormatter: string | EChartsTooltipValueFormatFunction): EChartsTooltipValueFormatFunction => {
let tooltipValueFormatFunction: EChartsTooltipValueFormatFunction;
if (isFunction(tooltipValueFormatter)) {
tooltipValueFormatFunction = tooltipValueFormatter as EChartsTooltipValueFormatFunction;
} else if (typeof tooltipValueFormatter === 'string' && tooltipValueFormatter.length) {
try {
tooltipValueFormatFunction =
new Function('value', 'latestData', tooltipValueFormatter) as EChartsTooltipValueFormatFunction;
} catch (e) {}
}
return tooltipValueFormatFunction;
};
export const echartsTooltipFormatter = (renderer: Renderer2,
tooltipDateFormat: DateFormatProcessor,
settings: EChartsTooltipWidgetSettings,
params: CallbackDataParams[] | CallbackDataParams,
decimals: number,
units: string,
valueFormatFunction: EChartsTooltipValueFormatFunction,
focusedSeriesIndex: number,
series?: EChartsSeriesItem[],
interval?: Interval): null | HTMLElement => {
@ -499,10 +548,12 @@ export const echartsTooltipFormatter = (renderer: Renderer2,
seriesParams = params;
}
if (seriesParams) {
renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units, series));
renderer.appendChild(tooltipElement,
constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, valueFormatFunction, series));
} else if (Array.isArray(params)) {
for (seriesParams of params) {
renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units, series));
renderer.appendChild(tooltipElement,
constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, valueFormatFunction, series));
}
}
return tooltipElement;
@ -511,8 +562,7 @@ export const echartsTooltipFormatter = (renderer: Renderer2,
const constructEchartsTooltipSeriesElement = (renderer: Renderer2,
settings: EChartsTooltipWidgetSettings,
seriesParams: CallbackDataParams,
decimals: number,
units: string,
valueFormatFunction: EChartsTooltipValueFormatFunction,
series?: EChartsSeriesItem[]): HTMLElement => {
const labelValueElement: HTMLElement = renderer.createElement('div');
renderer.setStyle(labelValueElement, 'display', 'flex');
@ -542,16 +592,25 @@ const constructEchartsTooltipSeriesElement = (renderer: Renderer2,
renderer.setStyle(labelTextElement, 'color', 'rgba(0, 0, 0, 0.76)');
renderer.appendChild(labelElement, labelTextElement);
const valueElement: HTMLElement = renderer.createElement('div');
let formatDecimals = decimals;
let formatUnits = units;
let formatFunction = valueFormatFunction;
let latestData: FormattedData;
let units = '';
let decimals = 0;
if (series) {
const item = series.find(s => s.id === seriesParams.seriesId);
if (item) {
formatDecimals = item.decimals;
formatUnits = item.units;
if (item.tooltipValueFormatFunction) {
formatFunction = item.tooltipValueFormatFunction;
}
latestData = item.latestData;
units = item.units;
decimals = item.decimals;
}
}
const value = formatValue(seriesParams.value[1], formatDecimals, formatUnits, false);
if (!latestData) {
latestData = {} as FormattedData;
}
const value = formatFunction(seriesParams.value[1], latestData, units, decimals);
renderer.appendChild(valueElement, renderer.createText(value));
renderer.setStyle(valueElement, 'flex', '1');
renderer.setStyle(valueElement, 'text-align', 'end');

View File

@ -0,0 +1,109 @@
///
/// 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 {
TimeSeriesChartStateSettings,
TimeSeriesChartStateSourceType,
TimeSeriesChartTicksFormatter,
TimeSeriesChartTicksGenerator
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { UtilsService } from '@core/services/utils.service';
import { EChartsTooltipValueFormatFunction } from '@home/components/widget/lib/chart/echarts-widget.models';
import { FormattedData } from '@shared/models/widget.models';
import { formatValue, isDefinedAndNotNull, isNumber, isNumeric } from '@core/utils';
import { LabelFormatterCallback } from 'echarts';
export class TimeSeriesChartStateValueConverter {
private readonly constantsMap = new Map<any, number>();
private readonly rangeStates: TimeSeriesChartStateSettings[] = [];
private readonly ticks: {value: number}[] = [];
private readonly labelsMap = new Map<number, string>();
public readonly ticksGenerator: TimeSeriesChartTicksGenerator;
public readonly ticksFormatter: TimeSeriesChartTicksFormatter;
public readonly tooltipFormatter: EChartsTooltipValueFormatFunction;
public readonly labelFormatter: LabelFormatterCallback;
public readonly valueConverter: (value: any) => any;
constructor(utils: UtilsService,
states: TimeSeriesChartStateSettings[]) {
const ticks: number[] = [];
for (const state of states) {
if (state.sourceType === TimeSeriesChartStateSourceType.constant) {
this.constantsMap.set(state.sourceValue, state.value);
} else {
this.rangeStates.push(state);
}
if (!ticks.includes(state.value)) {
ticks.push(state.value);
const label = utils.customTranslation(state.label, state.label);
this.labelsMap.set(state.value, label);
}
}
this.ticks = ticks.map(val => ({value: val}));
this.ticksGenerator = () => this.ticks;
this.ticksFormatter = (value: any) => {
const result = this.labelsMap.get(value);
return result || '';
};
this.tooltipFormatter = (value: any, latestData: FormattedData, units?: string, decimals?: number) => {
const result = this.labelsMap.get(value);
if (typeof result === 'string') {
return result;
} else {
return formatValue(value, decimals, units, false);
}
};
this.labelFormatter = (params) => {
const value = params.value[1];
const result = this.labelsMap.get(value);
if (typeof result === 'string') {
return `{value|${result}}`;
} else {
return undefined;
}
};
this.valueConverter = (value: any) => {
let key = value;
if (key === 'true') {
key = true;
} else if (key === 'false') {
key = false;
}
const result = this.constantsMap.get(key);
if (typeof result === 'number') {
return result;
} else if (this.rangeStates.length && isDefinedAndNotNull(value) && isNumeric(value)) {
for (const state of this.rangeStates) {
const num = Number(value);
if (TimeSeriesChartStateValueConverter.constantRange(state) && state.sourceRangeFrom === num) {
return state.value;
} else if ((!isNumber(state.sourceRangeFrom) || num >= state.sourceRangeFrom) &&
(!isNumber(state.sourceRangeTo) || num < state.sourceRangeTo)) {
return state.value;
}
}
}
return value;
};
}
static constantRange(state: TimeSeriesChartStateSettings): boolean {
return isNumber(state.sourceRangeFrom) && isNumber(state.sourceRangeTo) && state.sourceRangeFrom === state.sourceRangeTo;
}
}

View File

@ -17,8 +17,10 @@
import {
ECharts,
EChartsOption,
EChartsSeriesItem, EChartsShape,
EChartsSeriesItem,
EChartsShape,
EChartsTooltipTrigger,
EChartsTooltipValueFormatFunction,
EChartsTooltipWidgetSettings,
measureThresholdOffset,
timeAxisBandWidthCalculator
@ -41,7 +43,8 @@ import { CustomSeriesOption, LineSeriesOption } from 'echarts/charts';
import {
formatValue,
isDefinedAndNotNull,
isFunction, isNumber,
isFunction,
isNumber,
isNumeric,
isUndefined,
isUndefinedOrNull,
@ -51,7 +54,8 @@ import {
import { LinearGradientObject } from 'zrender/lib/graphic/LinearGradient';
import tinycolor from 'tinycolor2';
import { ValueAxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes';
import { LabelFormatterCallback, LabelLayoutOption, SeriesLabelOption } from 'echarts/types/src/util/types';
import { LabelLayoutOption, SeriesLabelOption } from 'echarts/types/src/util/types';
import { LabelFormatterCallback } from 'echarts';
import {
BarRenderContext,
BarRenderSharedContext,
@ -70,7 +74,8 @@ export enum TimeSeriesChartType {
default = 'default',
line = 'line',
bar = 'bar',
point = 'point'
point = 'point',
state = 'state'
}
export const timeSeriesChartTypeTranslations = new Map<TimeSeriesChartType, string>(
@ -200,6 +205,21 @@ export const timeSeriesThresholdTypeTranslations = new Map<TimeSeriesChartThresh
]
);
export enum TimeSeriesChartStateSourceType {
constant = 'constant',
range = 'range'
}
export const timeSeriesStateSourceTypes = Object.keys(TimeSeriesChartStateSourceType) as TimeSeriesChartStateSourceType[];
export const timeSeriesStateSourceTypeTranslations = new Map<TimeSeriesChartStateSourceType, string>(
[
[TimeSeriesChartStateSourceType.constant, 'widgets.time-series-chart.state.type-constant'],
[TimeSeriesChartStateSourceType.range, 'widgets.time-series-chart.state.type-range']
]
);
export enum SeriesFillType {
none = 'none',
opacity = 'opacity',
@ -639,6 +659,39 @@ export interface TimeSeriesChartVisualMapSettings {
pieces: TimeSeriesChartVisualMapPiece[];
}
export interface TimeSeriesChartStateSettings {
label: string;
value: number;
sourceType: TimeSeriesChartStateSourceType;
sourceValue?: any;
sourceRangeFrom?: number;
sourceRangeTo?: number;
}
export const timeSeriesChartStateValid = (state: TimeSeriesChartStateSettings): boolean => {
if (isUndefinedOrNull(state.value) || !state.sourceType) {
return false;
}
switch (state.sourceType) {
case TimeSeriesChartStateSourceType.constant:
if (isUndefinedOrNull(state.sourceValue)) {
return false;
}
break;
}
return true;
};
export const timeSeriesChartStateValidator = (control: AbstractControl): ValidationErrors | null => {
const state: TimeSeriesChartStateSettings = control.value;
if (!timeSeriesChartStateValid(state)) {
return {
state: true
};
}
return null;
};
export interface TimeSeriesChartSettings extends EChartsTooltipWidgetSettings {
thresholds: TimeSeriesChartThreshold[];
darkMode: boolean;
@ -650,6 +703,7 @@ export interface TimeSeriesChartSettings extends EChartsTooltipWidgetSettings {
barWidthSettings: TimeSeriesChartBarWidthSettings;
noAggregationBarWidthSettings: TimeSeriesChartNoAggregationBarWidthSettings;
visualMapSettings?: TimeSeriesChartVisualMapSettings;
states?: TimeSeriesChartStateSettings[];
}
export const timeSeriesChartDefaultSettings: TimeSeriesChartSettings = {
@ -751,6 +805,7 @@ export interface TimeSeriesChartKeySettings {
type: TimeSeriesChartSeriesType;
lineSettings: LineSeriesSettings;
barSettings: BarSeriesSettings;
tooltipValueFormatter?: string | EChartsTooltipValueFormatFunction;
}
export const timeSeriesChartKeyDefaultSettings: TimeSeriesChartKeySettings = {
@ -1359,28 +1414,28 @@ const createSeriesLabelOption = (item: TimeSeriesChartDataItem, show: boolean,
if (show) {
labelStyle = createChartTextStyle(labelFont, labelColor, darkMode, 'series.label', labelColorFill);
}
let formatter: LabelFormatterCallback;
if (isFunction(labelFormatter)) {
formatter = labelFormatter as LabelFormatterCallback;
} else if (labelFormatter?.length) {
const formatFunction = parseFunction(labelFormatter, ['value']);
formatter = (params): string => {
let result: string;
try {
result = formatFunction(params.value[1]);
} catch (_e) {
}
if (isUndefined(result)) {
result = formatValue(params.value[1], item.decimals, item.units, false);
}
return `{value|${result}}`;
};
} else {
formatter = (params): string => {
const value = formatValue(params.value[1], item.decimals, item.units, false);
return `{value|${value}}`;
};
let formatFunction: (...args: any[]) => any;
if (typeof labelFormatter === 'string' && labelFormatter.length) {
formatFunction = parseFunction(labelFormatter, ['value']);
}
const formatter: LabelFormatterCallback = (params): string => {
let result: string;
if (typeof labelFormatter === 'string') {
if (formatFunction) {
try {
result = formatFunction(params.value[1]);
} catch (_e) {
}
}
} else if (isFunction(labelFormatter)) {
result = labelFormatter(params);
}
if (isUndefined(result)) {
result = formatValue(params.value[1], item.decimals, item.units, false);
result = `{value|${result}}`;
}
return result;
};
const labelOption: SeriesLabelOption = {
show,
position,

View File

@ -23,6 +23,7 @@ import {
createTimeSeriesYAxis,
defaultTimeSeriesChartYAxisSettings,
generateChartData,
LineSeriesStepType,
parseThresholdData,
SeriesLabelPosition,
TimeSeriesChartDataItem,
@ -44,13 +45,17 @@ import {
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { ResizeObserver } from '@juggle/resize-observer';
import {
adjustTimeAxisExtentToData,
calculateXAxisHeight,
calculateYAxisWidth,
createTooltipValueFormatFunction,
ECharts,
echartsModule,
EChartsOption, EChartsShape,
EChartsOption,
EChartsShape,
echartsTooltipFormatter,
EChartsTooltipTrigger,
EChartsTooltipValueFormatFunction,
getAxisExtent,
getFocusedSeriesIndex,
measureXAxisNameHeight,
@ -58,12 +63,11 @@ import {
toNamedData
} from '@home/components/widget/lib/chart/echarts-widget.models';
import { DateFormatProcessor } from '@shared/models/widget-settings.models';
import { isDefinedAndNotNull, isEqual, mergeDeep } from '@core/utils';
import { DataKey, Datasource, DatasourceType, widgetType } from '@shared/models/widget.models';
import { formattedDataFormDatasourceData, formatValue, isDefinedAndNotNull, isEqual, mergeDeep } from '@core/utils';
import { DataKey, Datasource, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models';
import * as echarts from 'echarts/core';
import { CallbackDataParams, PiecewiseVisualMapOption } 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';
@ -71,6 +75,7 @@ import { WidgetSubscriptionOptions } from '@core/api/widget-api.models';
import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models';
import { DeepPartial } from '@shared/models/common';
import { BarRenderSharedContext } from '@home/components/widget/lib/chart/time-series-chart-bar.models';
import { TimeSeriesChartStateValueConverter } from '@home/components/widget/lib/chart/time-series-chart-state.models';
export class TbTimeSeriesChart {
@ -89,6 +94,13 @@ export class TbTimeSeriesChart {
settings.lineSettings.showPoints = true;
settings.lineSettings.pointShape = EChartsShape.circle;
settings.lineSettings.pointSize = 8;
} else if (type === TimeSeriesChartType.state) {
settings.type = TimeSeriesChartSeriesType.line;
settings.lineSettings.showLine = true;
settings.lineSettings.step = true;
settings.lineSettings.stepType = LineSeriesStepType.end;
settings.lineSettings.pointShape = EChartsShape.circle;
settings.lineSettings.pointSize = 12;
}
return settings;
}
@ -97,7 +109,11 @@ export class TbTimeSeriesChart {
}
private get noAggregation(): boolean {
return this.ctx.defaultSubscription.timeWindowConfig?.aggregation?.type === AggregationType.NONE;
return this.ctx.defaultSubscription.subscriptionTimewindow?.aggregation?.type === AggregationType.NONE;
}
private get stateData(): boolean {
return this.ctx.defaultSubscription.subscriptionTimewindow?.aggregation?.stateData === true;
}
private readonly shapeResize$: ResizeObserver;
@ -115,6 +131,8 @@ export class TbTimeSeriesChart {
private timeSeriesChartOptions: EChartsOption;
private readonly tooltipDateFormat: DateFormatProcessor;
private readonly tooltipValueFormatFunction: EChartsTooltipValueFormatFunction;
private readonly stateValueConverter: TimeSeriesChartStateValueConverter;
private yMinSubject = new BehaviorSubject(-1);
private yMaxSubject = new BehaviorSubject(1);
@ -131,6 +149,8 @@ export class TbTimeSeriesChart {
private barRenderSharedContext: BarRenderSharedContext;
private latestData: FormattedData[] = [];
yMin$ = this.yMinSubject.asObservable();
yMax$ = this.yMaxSubject.asObservable();
@ -143,6 +163,10 @@ export class TbTimeSeriesChart {
this.settings = mergeDeep({} as TimeSeriesChartSettings,
timeSeriesChartDefaultSettings,
this.inputSettings as TimeSeriesChartSettings);
if (this.settings.states && this.settings.states.length) {
this.stateValueConverter = new TimeSeriesChartStateValueConverter(this.ctx.dashboard.utils, this.settings.states);
this.tooltipValueFormatFunction = this.stateValueConverter.tooltipFormatter;
}
const $dashboardPageElement = this.ctx.$containerParent.parents('.tb-dashboard-page');
const dashboardPageElement = $dashboardPageElement.length ? $($dashboardPageElement[$dashboardPageElement.length-1]) : null;
this.darkMode = this.settings.darkMode || dashboardPageElement?.hasClass('dark');
@ -150,8 +174,17 @@ export class TbTimeSeriesChart {
this.setupData();
this.setupThresholds();
this.setupVisualMap();
if (this.settings.showTooltip && this.settings.tooltipShowDate) {
this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat);
if (this.settings.showTooltip) {
if (this.settings.tooltipShowDate) {
this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat);
}
if (!this.tooltipValueFormatFunction) {
this.tooltipValueFormatFunction =
createTooltipValueFormatFunction(this.settings.tooltipValueFormatter);
if (!this.tooltipValueFormatFunction) {
this.tooltipValueFormatFunction = (value, latestData, units, decimals) => formatValue(value, decimals, units, false);
}
}
}
this.onResize();
if (this.autoResize) {
@ -178,7 +211,7 @@ export class TbTimeSeriesChart {
const datasourceData = this.ctx.data ? this.ctx.data.find(d => d.dataKey === item.dataKey) : null;
if (!isEqual(item.dataSet, datasourceData?.data)) {
item.dataSet = datasourceData?.data;
item.data = datasourceData?.data ? toNamedData(datasourceData.data) : [];
item.data = datasourceData?.data ? toNamedData(datasourceData.data, this.stateValueConverter?.valueConverter) : [];
}
}
this.onResize();
@ -205,6 +238,14 @@ export class TbTimeSeriesChart {
public latestUpdated() {
let update = false;
if (this.ctx.latestData) {
this.latestData = formattedDataFormDatasourceData(this.ctx.latestData);
for (const item of this.dataItems) {
let latestData = this.latestData.find(data => data.$datasource === item.datasource);
if (!latestData) {
latestData = {} as FormattedData;
}
item.latestData = 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);
@ -253,7 +294,7 @@ export class TbTimeSeriesChart {
seriesId: dataItem.id
});
}
this.timeSeriesChartOptions.series = this.updateSeries();
this.updateSeries();
const mergeList = ['series'];
if (this.updateYAxisScale(this.yAxisList)) {
this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
@ -338,9 +379,12 @@ export class TbTimeSeriesChart {
.includes(keySettings.barSettings.labelPosition as SeriesLabelPosition))) {
this.topPointLabels = true;
}
if (this.stateValueConverter && keySettings.type === TimeSeriesChartSeriesType.line) {
keySettings.lineSettings.pointLabelFormatter = this.stateValueConverter.labelFormatter;
}
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 namedData = datasourceData?.data ? toNamedData(datasourceData.data, this.stateValueConverter?.valueConverter) : [];
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);
@ -354,9 +398,11 @@ export class TbTimeSeriesChart {
decimals,
yAxisId,
yAxisIndex: this.getYAxisIndex(yAxisId),
datasource,
dataKey,
data: namedData,
enabled: !keySettings.dataHiddenByDefault
enabled: !keySettings.dataHiddenByDefault,
tooltipValueFormatFunction: createTooltipValueFormatFunction(keySettings.tooltipValueFormatter)
});
}
}
@ -451,6 +497,10 @@ export class TbTimeSeriesChart {
const units = axisSettings.units && axisSettings.units.length ? axisSettings.units : this.ctx.units;
const decimals = isDefinedAndNotNull(axisSettings.decimals) ? axisSettings.decimals :
(isDefinedAndNotNull(this.ctx.decimals) ? this.ctx.decimals : 2);
if (this.stateValueConverter) {
axisSettings.ticksGenerator = this.stateValueConverter.ticksGenerator;
axisSettings.ticksFormatter = this.stateValueConverter.ticksFormatter;
}
const yAxis = createTimeSeriesYAxis(units, decimals, axisSettings, this.darkMode);
this.yAxisList.push(yAxis);
}
@ -528,7 +578,7 @@ export class TbTimeSeriesChart {
},
formatter: (params: CallbackDataParams[]) =>
this.settings.showTooltip ? echartsTooltipFormatter(this.renderer, this.tooltipDateFormat,
this.settings, params, 0, '',
this.settings, params, this.tooltipValueFormatFunction,
this.settings.tooltipShowFocusedSeries ? getFocusedSeriesIndex(this.timeSeriesChart) : -1,
this.dataItems, this.noAggregation ? null : this.ctx.timeWindow.interval) : undefined,
padding: [8, 12],
@ -551,13 +601,15 @@ export class TbTimeSeriesChart {
{
type: 'inside',
disabled: !this.settings.dataZoom,
realtime: true
realtime: true,
filterMode: this.stateData ? 'none' : 'filter'
},
{
type: 'slider',
show: this.settings.dataZoom,
showDetail: false,
realtime: true,
filterMode: this.stateData ? 'none' : 'filter',
bottom: 10
}
],
@ -577,7 +629,7 @@ export class TbTimeSeriesChart {
this.timeSeriesChartOptions.xAxis[0].tbTimeWindow = this.ctx.defaultSubscription.timeWindow;
this.timeSeriesChartOptions.series = this.updateSeries();
this.updateSeries();
if (this.updateYAxisScale(this.yAxisList)) {
this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
}
@ -593,7 +645,7 @@ export class TbTimeSeriesChart {
}
private updateSeriesData(updateScale = false): void {
this.timeSeriesChartOptions.series = this.updateSeries();
this.updateSeries();
if (updateScale && this.updateYAxisScale(this.yAxisList)) {
this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
}
@ -601,11 +653,16 @@ export class TbTimeSeriesChart {
this.updateAxes();
}
private updateSeries(): Array<LineSeriesOption | CustomSeriesOption> {
return generateChartData(this.dataItems, this.thresholdItems,
private updateSeries(): void {
this.timeSeriesChartOptions.series = generateChartData(this.dataItems, this.thresholdItems,
this.settings.stack,
this.noAggregation,
this.barRenderSharedContext, this.darkMode);
if (this.stateData) {
adjustTimeAxisExtentToData(this.timeSeriesChartOptions.xAxis[0], this.dataItems,
this.ctx.defaultSubscription.timeWindow.minTime,
this.ctx.defaultSubscription.timeWindow.maxTime);
}
}
private updateAxes(lazy = true) {

View File

@ -56,6 +56,16 @@
formControlName="barSettings">
</tb-time-series-chart-bar-settings>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.chart.tooltip-settings</div>
<tb-js-func
formControlName="tooltipValueFormatter"
[globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'latestData']"
functionTitle="{{ 'widgets.chart.tooltip-value-format-function' | translate }}"
helpId="widget/lib/flot/tooltip_value_format_fn">
</tb-js-func>
</div>
</ng-container>
<ng-template #chartTypeTitle>
<div class="tb-form-panel-title">{{ timeSeriesChartTypeTranslations.get(chartType) | translate }}</div>

View File

@ -29,6 +29,7 @@ import {
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { TimeSeriesChartWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-widget.models';
import { WidgetService } from '@core/http/widget.service';
@Component({
selector: 'tb-time-series-chart-key-settings',
@ -53,7 +54,10 @@ export class TimeSeriesChartKeySettingsComponent extends WidgetSettingsComponent
yAxisIds: TimeSeriesChartYAxisId[];
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
constructor(protected store: Store<AppState>,
private widgetService: WidgetService,
private fb: UntypedFormBuilder) {
super(store);
}
@ -88,7 +92,8 @@ export class TimeSeriesChartKeySettingsComponent extends WidgetSettingsComponent
dataHiddenByDefault: [seriesSettings.dataHiddenByDefault, []],
type: [seriesSettings.type, []],
lineSettings: [seriesSettings.lineSettings, []],
barSettings: [seriesSettings.barSettings, []]
barSettings: [seriesSettings.barSettings, []],
tooltipValueFormatter: [seriesSettings.tooltipValueFormatter, []],
});
}

View File

@ -18,7 +18,7 @@
<ng-container [formGroup]="lineSettingsFormGroup">
<div *ngIf="chartType !== TimeSeriesChartType.point" class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>widgets.time-series-chart.series.line.line</div>
<div class="tb-form-row">
<div *ngIf="chartType !== TimeSeriesChartType.state" class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="showLine">
{{ 'widgets.time-series-chart.series.line.show-line' | translate }}
</mat-slide-toggle>
@ -35,7 +35,7 @@
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row">
<div *ngIf="chartType !== TimeSeriesChartType.state" class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="smooth">
{{ 'widgets.time-series-chart.series.line.smooth-line' | translate }}
</mat-slide-toggle>

View File

@ -147,12 +147,22 @@ export class TimeSeriesChartLineSettingsComponent implements OnInit, ControlValu
}
private updateValidators() {
const state = this.chartType === TimeSeriesChartType.state;
const showLine: boolean = this.lineSettingsFormGroup.get('showLine').value;
const step: boolean = this.lineSettingsFormGroup.get('step').value;
const showPointLabel: boolean = this.lineSettingsFormGroup.get('showPointLabel').value;
const enablePointLabelBackground: boolean = this.lineSettingsFormGroup.get('enablePointLabelBackground').value;
if (state) {
this.lineSettingsFormGroup.get('showLine').disable({emitEvent: false});
} else {
this.lineSettingsFormGroup.get('showLine').enable({emitEvent: false});
}
if (showLine) {
this.lineSettingsFormGroup.get('step').enable({emitEvent: false});
if (state) {
this.lineSettingsFormGroup.get('step').disable({emitEvent: false});
} else {
this.lineSettingsFormGroup.get('step').enable({emitEvent: false});
}
if (step) {
this.lineSettingsFormGroup.get('stepType').enable({emitEvent: false});
this.lineSettingsFormGroup.get('smooth').disable({emitEvent: false});

View File

@ -16,6 +16,10 @@
-->
<ng-container [formGroup]="timeSeriesChartWidgetSettingsForm">
<tb-time-series-chart-states-panel
*ngIf="chartType === TimeSeriesChartType.state"
formControlName="states">
</tb-time-series-chart-states-panel>
<tb-time-series-chart-y-axes-panel
formControlName="yAxes"
(axisRemoved)="yAxisRemoved($event)"
@ -118,6 +122,13 @@
</tb-color-input>
</div>
</div>
<tb-js-func
formControlName="tooltipValueFormatter"
[globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'latestData']"
functionTitle="{{ 'widgets.chart.tooltip-value-format-function' | translate }}"
helpId="widget/lib/flot/tooltip_value_format_fn">
</tb-js-func>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="tooltipShowDate">
{{ 'tooltip.date' | translate }}

View File

@ -39,6 +39,7 @@ import {
TimeSeriesChartYAxisId
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { WidgetService } from '@core/http/widget.service';
@Component({
selector: 'tb-time-series-chart-widget-settings',
@ -77,8 +78,11 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon
chartType: TimeSeriesChartType = TimeSeriesChartType.default;
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
constructor(protected store: Store<AppState>,
private $injector: Injector,
private widgetService: WidgetService,
private fb: UntypedFormBuilder) {
super(store);
}
@ -129,6 +133,7 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon
tooltipTrigger: [settings.tooltipTrigger, []],
tooltipValueFont: [settings.tooltipValueFont, []],
tooltipValueColor: [settings.tooltipValueColor, []],
tooltipValueFormatter: [settings.tooltipValueFormatter, []],
tooltipShowDate: [settings.tooltipShowDate, []],
tooltipDateFormat: [settings.tooltipDateFormat, []],
tooltipDateFont: [settings.tooltipDateFont, []],
@ -143,6 +148,9 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon
background: [settings.background, []],
padding: [settings.padding, []]
});
if (this.chartType === TimeSeriesChartType.state) {
this.timeSeriesChartWidgetSettingsForm.addControl('states', this.fb.control(settings.states, []));
}
}
protected validatorTriggers(): string[] {
@ -168,6 +176,7 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon
this.timeSeriesChartWidgetSettingsForm.get('tooltipTrigger').enable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFont').enable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipValueColor').enable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFormatter').enable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').enable({emitEvent: false});
this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundColor').enable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundBlur').enable();
@ -185,6 +194,7 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon
} else {
this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFont').disable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipValueColor').disable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFormatter').disable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').disable({emitEvent: false});
this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFormat').disable();
this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFont').disable();

View File

@ -68,6 +68,15 @@
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div *ngIf="axisType === 'yAxis' && advanced" class="tb-form-row">
<tb-js-func fxFlex
formControlName="ticksGenerator"
[globalVariables]="functionScopeVariables"
[functionArgs]="['extent']"
functionTitle="{{ 'widgets.time-series-chart.axis.ticks-generator-function' | translate }}"
helpId="widget/lib/chart/ticks_generator_fn">
</tb-js-func>
</div>
<div class="tb-form-panel no-padding stroked">
<div class="tb-form-row no-border space-between column-xs">
<mat-slide-toggle class="mat-slide" formControlName="showTickLabels">

View File

@ -85,7 +85,7 @@ export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValu
public axisSettingsFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder,
private widgetService: WidgetService,) {
private widgetService: WidgetService) {
}
ngOnInit(): void {
@ -113,6 +113,7 @@ export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValu
this.axisSettingsFormGroup.addControl('units', this.fb.control(null, []));
this.axisSettingsFormGroup.addControl('decimals', this.fb.control(null, [Validators.min(0)]));
this.axisSettingsFormGroup.addControl('ticksFormatter', this.fb.control(null, []));
this.axisSettingsFormGroup.addControl('ticksGenerator', this.fb.control(null, []));
this.axisSettingsFormGroup.addControl('interval', this.fb.control(null, [Validators.min(0)]));
this.axisSettingsFormGroup.addControl('splitNumber', this.fb.control(null, [Validators.min(1)]));
this.axisSettingsFormGroup.addControl('min', this.fb.control(null, []));

View File

@ -0,0 +1,62 @@
<!--
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 [formGroup]="stateFormGroup" class="tb-form-table-row tb-time-series-state-row">
<mat-form-field appearance="outline" class="tb-inline-field tb-state-label-field" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<mat-form-field appearance="outline" class="tb-inline-field number tb-state-value-field" subscriptSizing="dynamic">
<input matInput formControlName="value" type="number" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<mat-form-field class="tb-inline-field tb-state-source-field" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="sourceType">
<mat-option *ngFor="let type of timeSeriesStateSourceTypes" [value]="type">
{{ timeSeriesStateSourceTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<div class="tb-state-source-value-field">
<tb-value-input
*ngIf="stateFormGroup.get('sourceType').value === TimeSeriesChartStateSourceType.constant"
formControlName="sourceValue"
fxFlex
[layout]="{
layout: 'column',
breakpoints: {'gt-sm': 'row'}
}">
</tb-value-input>
<mat-form-field
*ngIf="stateFormGroup.get('sourceType').value === TimeSeriesChartStateSourceType.range"
appearance="outline" class="tb-inline-field number" subscriptSizing="dynamic">
<input matInput formControlName="sourceRangeFrom" type="number" placeholder="{{ 'widgets.time-series-chart.state.from' | translate }}">
</mat-form-field>
<mat-form-field
*ngIf="stateFormGroup.get('sourceType').value === TimeSeriesChartStateSourceType.range"
appearance="outline" class="tb-inline-field number" subscriptSizing="dynamic">
<input matInput formControlName="sourceRangeTo" type="number" placeholder="{{ 'widgets.time-series-chart.state.to' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-table-row-cell-buttons">
<button type="button"
mat-icon-button
(click)="stateRemoved.emit()"
matTooltip="{{ 'widgets.time-series-chart.state.remove-state' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>

View File

@ -0,0 +1,51 @@
/**
* 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 '../../../../../../../../../scss/constants';
.tb-form-table-row.tb-time-series-state-row {
@media #{$mat-lt-md} {
align-items: flex-start;
}
.tb-state-label-field {
flex: 1;
min-width: 70px;
}
.tb-state-value-field {
width: 80px;
min-width: 80px;
}
.tb-state-source-field {
width: 100px;
min-width: 100px;
}
.tb-state-source-value-field {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 8px;
@media #{$mat-gt-sm} {
min-width: 358px;
flex-direction: row;
align-items: center;
gap: 12px;
}
.tb-inline-field {
flex: 1;
}
}
}

View File

@ -0,0 +1,138 @@
///
/// 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 {
ChangeDetectorRef,
Component,
EventEmitter,
forwardRef,
Input,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormGroup,
Validators
} from '@angular/forms';
import {
TimeSeriesChartStateSettings,
TimeSeriesChartStateSourceType,
timeSeriesStateSourceTypes,
timeSeriesStateSourceTypeTranslations
} from '@home/components/widget/lib/chart/time-series-chart.models';
@Component({
selector: 'tb-time-series-chart-state-row',
templateUrl: './time-series-chart-state-row.component.html',
styleUrls: ['./time-series-chart-state-row.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TimeSeriesChartStateRowComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class TimeSeriesChartStateRowComponent implements ControlValueAccessor, OnInit {
TimeSeriesChartStateSourceType = TimeSeriesChartStateSourceType;
timeSeriesStateSourceTypes = timeSeriesStateSourceTypes;
timeSeriesStateSourceTypeTranslations = timeSeriesStateSourceTypeTranslations;
@Input()
disabled: boolean;
@Output()
stateRemoved = new EventEmitter();
stateFormGroup: UntypedFormGroup;
modelValue: TimeSeriesChartStateSettings;
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private cd: ChangeDetectorRef) {
}
ngOnInit() {
this.stateFormGroup = this.fb.group({
label: [null, []],
value: [null, [Validators.required]],
sourceType: [null, [Validators.required]],
sourceValue: [null, [Validators.required]],
sourceRangeFrom: [null, []],
sourceRangeTo: [null, []]
});
this.stateFormGroup.valueChanges.subscribe(
() => this.updateModel()
);
this.stateFormGroup.get('sourceType').valueChanges.subscribe(() => {
this.updateValidators();
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.stateFormGroup.disable({emitEvent: false});
} else {
this.stateFormGroup.enable({emitEvent: false});
this.updateValidators();
}
}
writeValue(value: TimeSeriesChartStateSettings): void {
this.modelValue = value;
this.stateFormGroup.patchValue(
value, {emitEvent: false}
);
this.updateValidators();
this.cd.markForCheck();
}
private updateValidators() {
const sourceType: TimeSeriesChartStateSourceType = this.stateFormGroup.get('sourceType').value;
if (sourceType === TimeSeriesChartStateSourceType.constant) {
this.stateFormGroup.get('sourceValue').enable({emitEvent: false});
this.stateFormGroup.get('sourceRangeFrom').disable({emitEvent: false});
this.stateFormGroup.get('sourceRangeTo').disable({emitEvent: false});
} else if (sourceType === TimeSeriesChartStateSourceType.range) {
this.stateFormGroup.get('sourceValue').disable({emitEvent: false});
this.stateFormGroup.get('sourceRangeFrom').enable({emitEvent: false});
this.stateFormGroup.get('sourceRangeTo').enable({emitEvent: false});
}
}
private updateModel() {
this.modelValue = this.stateFormGroup.value;
this.propagateChange(this.modelValue);
}
}

View File

@ -0,0 +1,47 @@
<!--
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-form-panel tb-time-series-states-panel">
<div class="tb-form-panel-title">{{ 'widgets.time-series-chart.state.states' | translate }}</div>
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell tb-state-label-header" translate>widgets.time-series-chart.state.label</div>
<div class="tb-form-table-header-cell tb-state-value-header" translate>widgets.time-series-chart.state.ticks-value</div>
<div class="tb-form-table-header-cell tb-state-source-header" translate>widgets.time-series-chart.state.source</div>
<div class="tb-form-table-header-cell tb-state-source-value-header" translate>widgets.time-series-chart.state.value-range</div>
<div class="tb-form-table-header-cell tb-actions-header"></div>
</div>
<div *ngIf="statesFormArray().controls.length; else noStates" class="tb-form-table-body">
<div *ngFor="let stateControl of statesFormArray().controls; trackBy: trackByState; let $index = index; let $last = last">
<tb-time-series-chart-state-row fxFlex
[formControl]="stateControl"
(stateRemoved)="removeState($index)">
</tb-time-series-chart-state-row>
<mat-divider *ngIf="!$last"></mat-divider>
</div>
</div>
</div>
<div>
<button type="button" mat-stroked-button color="primary" (click)="addState()">
{{ 'widgets.time-series-chart.state.add-state' | translate }}
</button>
</div>
</div>
<ng-template #noStates>
<span fxLayoutAlign="center center"
class="tb-prompt">{{ 'widgets.time-series-chart.state.no-states' | translate }}</span>
</ng-template>

View File

@ -0,0 +1,60 @@
/**
* 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 '../../../../../../../../../scss/constants';
.tb-time-series-states-panel {
.tb-form-table {
overflow-x: auto;
}
.tb-form-table-header-cell {
&.tb-state-label-header {
flex: 1;
min-width: 80px;
}
&.tb-state-value-header {
width: 80px;
min-width: 80px;
}
&.tb-state-source-header {
width: 100px;
min-width: 100px;
}
&.tb-state-source-value-header {
flex: 1;
min-width: 200px;
@media #{$mat-gt-sm} {
min-width: 358px;
}
}
&.tb-actions-header {
width: 40px;
min-width: 40px;
}
}
.tb-form-table-header {
min-width: fit-content;
}
.tb-form-table-body {
min-width: fit-content;
.mat-divider {
margin-top: 8px;
@media #{$mat-gt-sm} {
display: none;
}
}
}
}

View File

@ -0,0 +1,144 @@
///
/// 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 { Component, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator
} from '@angular/forms';
import {
TimeSeriesChartStateSettings,
TimeSeriesChartStateSourceType,
timeSeriesChartStateValid,
timeSeriesChartStateValidator
} from '@home/components/widget/lib/chart/time-series-chart.models';
@Component({
selector: 'tb-time-series-chart-states-panel',
templateUrl: './time-series-chart-states-panel.component.html',
styleUrls: ['./time-series-chart-states-panel.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TimeSeriesChartStatesPanelComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => TimeSeriesChartStatesPanelComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class TimeSeriesChartStatesPanelComponent implements ControlValueAccessor, OnInit, Validator {
@Input()
disabled: boolean;
statesFormGroup: UntypedFormGroup;
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder) {
}
ngOnInit() {
this.statesFormGroup = this.fb.group({
states: [this.fb.array([]), []]
});
this.statesFormGroup.valueChanges.subscribe(
() => {
let states: TimeSeriesChartStateSettings[] = this.statesFormGroup.get('states').value;
if (states) {
states = states.filter(s => timeSeriesChartStateValid(s));
}
this.propagateChange(states);
}
);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.statesFormGroup.disable({emitEvent: false});
} else {
this.statesFormGroup.enable({emitEvent: false});
}
}
writeValue(value: TimeSeriesChartStateSettings[] | undefined): void {
const states = value || [];
this.statesFormGroup.setControl('states', this.prepareStatesFormArray(states), {emitEvent: false});
}
public validate(c: UntypedFormControl) {
const valid = this.statesFormGroup.valid;
return valid ? null : {
states: {
valid: false,
},
};
}
statesFormArray(): UntypedFormArray {
return this.statesFormGroup.get('states') as UntypedFormArray;
}
trackByState(index: number, stateControl: AbstractControl): any {
return stateControl;
}
removeState(index: number) {
(this.statesFormGroup.get('states') as UntypedFormArray).removeAt(index);
}
addState() {
const state: TimeSeriesChartStateSettings = {
label: '',
value: 0,
sourceType: TimeSeriesChartStateSourceType.constant
};
const statesArray = this.statesFormGroup.get('states') as UntypedFormArray;
const stateControl = this.fb.control(state, [timeSeriesChartStateValidator]);
statesArray.push(stateControl);
}
private prepareStatesFormArray(states: TimeSeriesChartStateSettings[] | undefined): UntypedFormArray {
const statesControls: Array<AbstractControl> = [];
if (states) {
states.forEach((state) => {
statesControls.push(this.fb.control(state, [timeSeriesChartStateValidator]));
});
}
return this.fb.array(statesControls);
}
}

View File

@ -133,6 +133,12 @@ import {
import {
TimeSeriesChartThresholdSettingsComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings.component';
import {
TimeSeriesChartStateRowComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component';
import {
TimeSeriesChartStatesPanelComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component';
@NgModule({
declarations: [
@ -182,6 +188,8 @@ import {
TimeSeriesChartAnimationSettingsComponent,
TimeSeriesChartFillSettingsComponent,
TimeSeriesChartThresholdSettingsComponent,
TimeSeriesChartStatesPanelComponent,
TimeSeriesChartStateRowComponent,
DataKeyInputComponent,
EntityAliasInputComponent
],
@ -237,6 +245,8 @@ import {
TimeSeriesChartAnimationSettingsComponent,
TimeSeriesChartFillSettingsComponent,
TimeSeriesChartThresholdSettingsComponent,
TimeSeriesChartStatesPanelComponent,
TimeSeriesChartStateRowComponent,
DataKeyInputComponent,
EntityAliasInputComponent
],

View File

@ -0,0 +1,61 @@
#### Ticks generator function
<div class="divider"></div>
<br/>
*function (extent): {value: number}[]*
A JavaScript function used to generate Y axis ticks.
**Parameters:**
<ul>
<li><b>extent:</b> <code>number[]</code> - An array of two numbers holding axis min and max values <b>[axisMin, axisMax]</b>.
</li>
</ul>
**Returns:**
An array of tick values with the following structure:
```typescript
{
value: number
}
```
<div class="divider"></div>
##### Examples
* Always display only one tick in the middle:
```javascript
return extent ? [{ value: (extent[0] + extent[1]) / 2}] : [];
{:copy-code}
```
* Display only min and max ticks:
```javascript
if (extent) {
return [ {value: extent[0]}, {value: extent[1]} ];
} else {
return [];
}
{:copy-code}
```
* Disable ticks:
```javascript
return [];
{:copy-code}
```
* Constant ticks (1,2,3):
```javascript
return [ {value: 1}, {value: 2}, {value: 3} ];
{:copy-code}
```

View File

@ -6783,6 +6783,20 @@
"label-position-inside-end-bottom": "Inside end bottom",
"label-background": "Label background"
},
"state": {
"states": "States",
"label": "Label",
"ticks-value": "Ticks value",
"source": "Source",
"value-range": "Value / Range",
"no-states": "No states configured",
"add-state": "Add state",
"type-constant": "Constant",
"type-range": "Range",
"from": "From",
"to": "To",
"remove-state": "Remove state"
},
"axis": {
"axes": "Axes",
"x-axis": "X axis",
@ -6798,6 +6812,7 @@
"position-bottom": "Bottom",
"tick-labels": "Tick labels",
"ticks-formatter-function": "Ticks formatter function",
"ticks-generator-function": "Ticks generator function",
"show-ticks": "Show ticks",
"show-line": "Show line",
"show-split-lines": "Show split lines",