From ffe3acd92dd04bdebd82ec07873b54405ec6fb7c Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 15 Aug 2023 16:52:35 +0300 Subject: [PATCH] UI: Aggregated value card widget --- ui-ngx/src/app/core/auth/auth.service.ts | 8 +- ui-ngx/src/app/core/utils.ts | 12 +- .../basic/basic-widget-config.module.ts | 18 +- .../aggregated-data-key-row.component.html | 94 ++++++ .../aggregated-data-key-row.component.scss | 88 ++++++ .../aggregated-data-key-row.component.ts | 229 ++++++++++++++ .../aggregated-data-keys-panel.component.html | 51 +++ .../aggregated-data-keys-panel.component.scss | 69 +++++ .../aggregated-data-keys-panel.component.ts | 173 +++++++++++ ...ted-value-card-basic-config.component.html | 136 ++++++++ ...gated-value-card-basic-config.component.ts | 291 ++++++++++++++++++ .../data-key-config-dialog.component.html | 1 + .../data-key-config-dialog.component.ts | 9 +- .../config/data-key-config.component.html | 2 +- .../config/data-key-config.component.ts | 13 +- .../widget/config/datasource.component.ts | 6 +- .../widget/config/datasources.component.ts | 4 + .../timewindow-style-panel.component.html | 5 + .../timewindow-style-panel.component.ts | 3 +- .../config/widget-config.component.models.ts | 59 +++- ...ggregated-value-card-widget.component.html | 90 ++++++ ...ggregated-value-card-widget.component.scss | 156 ++++++++++ .../aggregated-value-card-widget.component.ts | 260 ++++++++++++++++ .../lib/cards/aggregated-value-card.models.ts | 232 ++++++++++++++ .../cards/value-card-widget.component.html | 2 +- .../lib/entities-hierarchy-widget.models.ts | 2 +- .../widget/lib/flot-widget.models.ts | 1 + .../home/components/widget/lib/flot-widget.ts | 46 ++- ...ted-value-card-key-settings.component.html | 48 +++ ...gated-value-card-key-settings.component.ts | 64 ++++ .../chart/flot-widget-settings.component.ts | 3 +- .../common/color-settings.component.html | 2 +- .../common/color-settings.component.ts | 2 +- .../lib/settings/widget-settings.module.ts | 12 +- .../widget/widget-components.module.ts | 9 +- .../components/time/timewindow.component.ts | 10 +- .../shared/models/widget-settings.models.ts | 60 +++- .../assets/locale/locale.constant-en_US.json | 26 +- ui-ngx/src/form.scss | 27 +- 39 files changed, 2258 insertions(+), 65 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index f1af3b745a..8a838b4130 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -385,10 +385,10 @@ export class AuthService { } else if (authPayload.authUser) { authPayload.authUser.authority = Authority.ANONYMOUS; } - if (authPayload.authUser.isPublic) { + if (authPayload.authUser?.isPublic) { authPayload.forceFullscreen = true; } - if (authPayload.authUser.isPublic) { + if (authPayload.authUser?.isPublic) { this.loadSystemParams().subscribe( (sysParams) => { authPayload = {...authPayload, ...sysParams}; @@ -399,10 +399,10 @@ export class AuthService { loadUserSubject.error(err); } ); - } else if (authPayload.authUser.authority === Authority.PRE_VERIFICATION_TOKEN) { + } else if (authPayload.authUser?.authority === Authority.PRE_VERIFICATION_TOKEN) { loadUserSubject.next(authPayload); loadUserSubject.complete(); - } else if (authPayload.authUser.userId) { + } else if (authPayload.authUser?.userId) { this.userService.getUser(authPayload.authUser.userId).subscribe( (user) => { authPayload.userDetails = user; diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 2a2f664c3a..8d48c5f038 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -130,7 +130,7 @@ export function isLiteralObject(value: any) { return (!!value) && (value.constructor === Object); } -export function formatValue(value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined { +export const formatValue = (value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined => { if (isDefinedAndNotNull(value) && isNumeric(value) && (isDefinedAndNotNull(dec) || isDefinedAndNotNull(units) || Number(value).toString() === value)) { let formatted: string | number = Number(value); @@ -150,6 +150,16 @@ export function formatValue(value: any, dec?: number, units?: string, showZeroDe } } +export const formatNumberValue = (value: any, dec?: number): number | undefined => { + if (isDefinedAndNotNull(value) && isNumeric(value)) { + let formatted: string | number = Number(value); + if (isDefinedAndNotNull(dec)) { + formatted = formatted.toFixed(dec); + } + return Number(formatted); + } +} + export function objectValues(obj: any): any[] { return Object.keys(obj).map(e => obj[e]); } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index 795d46984c..7c4168a786 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -40,6 +40,15 @@ import { import { ValueCardBasicConfigComponent } from '@home/components/widget/config/basic/cards/value-card-basic-config.component'; +import { + AggregatedValueCardBasicConfigComponent +} from '@home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component'; +import { + AggregatedDataKeyRowComponent +} from '@home/components/widget/config/basic/cards/aggregated-data-key-row.component'; +import { + AggregatedDataKeysPanelComponent +} from '@home/components/widget/config/basic/cards/aggregated-data-keys-panel.component'; @NgModule({ declarations: [ @@ -50,6 +59,9 @@ import { FlotBasicConfigComponent, AlarmsTableBasicConfigComponent, ValueCardBasicConfigComponent, + AggregatedValueCardBasicConfigComponent, + AggregatedDataKeyRowComponent, + AggregatedDataKeysPanelComponent, DataKeyRowComponent, DataKeysPanelComponent ], @@ -66,6 +78,9 @@ import { FlotBasicConfigComponent, AlarmsTableBasicConfigComponent, ValueCardBasicConfigComponent, + AggregatedValueCardBasicConfigComponent, + AggregatedDataKeyRowComponent, + AggregatedDataKeysPanelComponent, DataKeyRowComponent, DataKeysPanelComponent ] @@ -79,5 +94,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type +
+ + + + {{ aggregatedValueCardKeyPositionTranslationMap.get(position) | translate }} + + + + + + +
+
+
+ +
+
+ +
+
+
+ +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ +
+
+ + + f() + + + + + + + + {{ (modelValue?.aggregationType || aggregationTypes.NONE) }} + ({{ 'datakey.latest-value' | translate }}) + + + ({{ 'datakey.delta' | translate }}:{{ (modelValue?.comparisonResultType === comparisonResultTypes.DELTA_PERCENT ? 'datakey.percent' : 'datakey.absolute') | translate }}) + ({{ 'datakey.delta-calculation-result-previous-value' | translate }}) + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.scss new file mode 100644 index 0000000000..e07b9fa9ad --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.scss @@ -0,0 +1,88 @@ +/** + * Copyright © 2016-2023 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-aggregated-data-key-row { + .mat-mdc-form-field.tb-inline-field.tb-aggregation-field { + .mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) { + padding-left: 8px; + padding-right: 0; + .mat-mdc-form-field-infix { + padding-top: 0; + padding-bottom: 6px; + .mdc-evolution-chip-set .mdc-evolution-chip { + margin: 0; + } + input.mat-mdc-chip-input { + height: 32px; + margin-left: 0; + } + } + } + .mat-mdc-chip.mat-mdc-standard-chip.tb-datakey-chip { + .tb-attribute-chip { + .tb-chip-labels { + background: transparent; + } + } + } + } + + .tb-position-field { + width: 132px; + min-width: 132px; + } + + .tb-aggregation-field { + flex: 1; + min-width: 150px; + } + + .tb-units-field, .tb-decimals-field, .tb-font-field, .tb-color-field { + display: flex; + flex-direction: row; + place-content: center; + align-items: center; + } + + .tb-units-field { + width: 80px; + min-width: 80px; + } + + .tb-decimals-field { + width: 60px; + min-width: 60px; + } + + .tb-font-field { + width: 40px; + min-width: 40px; + } + + .tb-color-field { + width: 40px; + min-width: 40px; + } + + .tb-units-field, .tb-decimals-field { + display: none; + @media #{$mat-gt-sm} { + display: block; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts new file mode 100644 index 0000000000..106412cab0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts @@ -0,0 +1,229 @@ +/// +/// Copyright © 2016-2023 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, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { + ComparisonResultType, + DataKey, + DataKeyConfigMode, + DatasourceType, + widgetType +} from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { AggregationType } from '@shared/models/time/time.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { TranslateService } from '@ngx-translate/core'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { + DataKeyConfigDialogComponent, + DataKeyConfigDialogData +} from '@home/components/widget/config/data-key-config-dialog.component'; +import { deepClone, formatValue } from '@core/utils'; +import { + AggregatedValueCardKeyPosition, + aggregatedValueCardKeyPositionTranslations, + AggregatedValueCardKeySettings +} from '@home/components/widget/lib/cards/aggregated-value-card.models'; + +@Component({ + selector: 'tb-aggregated-data-key-row', + templateUrl: './aggregated-data-key-row.component.html', + styleUrls: ['./aggregated-data-key-row.component.scss', '../../data-keys.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AggregatedDataKeyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class AggregatedDataKeyRowComponent implements ControlValueAccessor, OnInit, OnChanges { + + aggregatedValueCardKeyPositions: AggregatedValueCardKeyPosition[] = + Object.keys(AggregatedValueCardKeyPosition).map(value => AggregatedValueCardKeyPosition[value]); + + aggregatedValueCardKeyPositionTranslationMap = aggregatedValueCardKeyPositionTranslations; + + dataKeyTypes = DataKeyType; + + aggregationTypes = AggregationType; + + comparisonResultTypes = ComparisonResultType; + + @Input() + disabled: boolean; + + @Input() + datasourceType: DatasourceType; + + @Input() + keyName: string; + + @Output() + keyRemoved = new EventEmitter(); + + keyRowFormGroup: UntypedFormGroup; + + modelValue: DataKey; + + valuePreviewFn = this._valuePreviewFn.bind(this); + + get callbacks(): DataKeysCallbacks { + return this.widgetConfigComponent.widgetConfigCallbacks; + } + + get isEntityDatasource(): boolean { + return [DatasourceType.device, DatasourceType.entity].includes(this.datasourceType); + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, + private cd: ChangeDetectorRef, + public translate: TranslateService, + public truncate: TruncatePipe, + private widgetConfigComponent: WidgetConfigComponent) { + } + + ngOnInit() { + this.keyRowFormGroup = this.fb.group({ + position: [null, []], + units: [null, []], + decimals: [null, []], + font: [null, []], + color: [null, []] + }); + this.keyRowFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['keyName'].includes(propName)) { + if (change.currentValue) { + this.modelValue.name = change.currentValue; + setTimeout(() => { + this.updateModel(); + }, 0); + } + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.keyRowFormGroup.disable({emitEvent: false}); + } else { + this.keyRowFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DataKey): void { + this.modelValue = value || {} as DataKey; + const settings: AggregatedValueCardKeySettings = (this.modelValue.settings || {}); + this.keyRowFormGroup.patchValue( + { + position: settings.position || AggregatedValueCardKeyPosition.center, + units: value?.units, + decimals: value?.decimals, + font: settings.font, + color: settings.color + }, {emitEvent: false} + ); + this.cd.markForCheck(); + } + + dataKeyHasPostprocessing(): boolean { + return !!this.modelValue?.postFuncBody; + } + + editKey() { + this.dialog.open(DataKeyConfigDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dataKey: deepClone(this.modelValue), + dataKeyConfigMode: DataKeyConfigMode.general, + dataKeySettingsSchema: null, + dataKeySettingsDirective: null, + dashboard: null, + aliasController: null, + widget: null, + widgetType: widgetType.latest, + deviceId: null, + entityAliasId: null, + showPostProcessing: true, + callbacks: this.callbacks, + hideDataKeyName: true, + hideDataKeyLabel: true, + hideDataKeyColor: true + } + }).afterClosed().subscribe((updatedDataKey) => { + if (updatedDataKey) { + this.modelValue = updatedDataKey; + this.keyRowFormGroup.get('units').patchValue(this.modelValue.units, {emitEvent: false}); + this.keyRowFormGroup.get('decimals').patchValue(this.modelValue.decimals, {emitEvent: false}); + this.updateModel(); + } + }); + } + + private updateModel() { + const value = this.keyRowFormGroup.value; + this.modelValue.settings = this.modelValue.settings || {}; + this.modelValue.settings.position = value.position; + this.modelValue.settings.font = value.font; + this.modelValue.settings.color = value.color; + this.modelValue.units = value.units; + this.modelValue.decimals = value.decimals; + this.propagateChange(this.modelValue); + } + + private _valuePreviewFn(): string { + const units: string = this.keyRowFormGroup.get('units').value; + const decimals: number = this.keyRowFormGroup.get('decimals').value; + return formatValue(22, decimals, units, true); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html new file mode 100644 index 0000000000..cee5b14dde --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html @@ -0,0 +1,51 @@ + +
+
{{ 'widgets.aggregated-value-card.values' | translate }}
+
+
+
widgets.aggregated-value-card.position
+
widgets.aggregated-value-card.aggregation
+
widget-config.units-short
+
widget-config.decimals-short
+
widgets.aggregated-value-card.font
+
widgets.aggregated-value-card.color
+
+
+
+
+ + +
+
+
+
+ +
+
+ + {{ 'widgets.aggregated-value-card.no-values' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.scss new file mode 100644 index 0000000000..9280b98ff6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.scss @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2023 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-aggregated-data-keys-panel { + .tb-form-table-header-cell { + &.tb-position-header { + width: 132px; + min-width: 132px; + } + + &.tb-aggregation-header { + flex: 1; + min-width: 150px; + } + + &.tb-units-header { + width: 80px; + min-width: 80px; + } + + &.tb-decimals-header { + width: 60px; + min-width: 60px; + } + + &.tb-font-header { + width: 40px; + min-width: 40px; + } + + &.tb-color-header { + width: 40px; + min-width: 40px; + } + + &.tb-actions-header { + width: 40px; + min-width: 40px; + } + + &.tb-units-header, &.tb-decimals-header { + display: none; + @media #{$mat-gt-sm} { + display: block; + } + } + } + .tb-form-table-body { + tb-aggregated-data-key-row { + overflow: hidden; + } + } +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts new file mode 100644 index 0000000000..d8b7f07f92 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts @@ -0,0 +1,173 @@ +/// +/// Copyright © 2016-2023 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, + forwardRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { UtilsService } from '@core/services/utils.service'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { aggregatedValueCardDefaultKeySettings } from '@home/components/widget/lib/cards/aggregated-value-card.models'; + +@Component({ + selector: 'tb-aggregated-data-keys-panel', + templateUrl: './aggregated-data-keys-panel.component.html', + styleUrls: ['./aggregated-data-keys-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AggregatedDataKeysPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class AggregatedDataKeysPanelComponent implements ControlValueAccessor, OnInit, OnChanges { + + @Input() + disabled: boolean; + + @Input() + datasourceType: DatasourceType; + + @Input() + keyName: string; + + dataKeyType: DataKeyType; + + keysListFormGroup: UntypedFormGroup; + + get widgetType(): widgetType { + return this.widgetConfigComponent.widgetType; + } + + get callbacks(): DataKeysCallbacks { + return this.widgetConfigComponent.widgetConfigCallbacks; + } + + get noKeys(): boolean { + const keys: DataKey[] = this.keysListFormGroup.get('keys').value; + return keys.length === 0; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, + private cd: ChangeDetectorRef, + private utils: UtilsService, + private widgetConfigComponent: WidgetConfigComponent) { + } + + ngOnInit() { + this.keysListFormGroup = this.fb.group({ + keys: [this.fb.array([]), []] + }); + this.keysListFormGroup.valueChanges.subscribe( + (val) => this.propagateChange(this.keysListFormGroup.get('keys').value) + ); + this.updateParams(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['datasourceType'].includes(propName)) { + this.updateParams(); + } + } + } + } + + private updateParams() { + if (this.datasourceType === DatasourceType.function) { + this.dataKeyType = DataKeyType.function; + } else { + this.dataKeyType = DataKeyType.timeseries; + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.keysListFormGroup.disable({emitEvent: false}); + } else { + this.keysListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DataKey[] | undefined): void { + this.keysListFormGroup.setControl('keys', this.prepareKeysFormArray(value), {emitEvent: false}); + } + + keysFormArray(): UntypedFormArray { + return this.keysListFormGroup.get('keys') as UntypedFormArray; + } + + trackByKey(index: number, keyControl: AbstractControl): any { + return keyControl; + } + + removeKey(index: number) { + (this.keysListFormGroup.get('keys') as UntypedFormArray).removeAt(index); + } + + addKey() { + const dataKey = this.callbacks.generateDataKey(this.keyName, this.dataKeyType, null); + dataKey.decimals = 0; + dataKey.settings = {...aggregatedValueCardDefaultKeySettings}; + const keysArray = this.keysListFormGroup.get('keys') as UntypedFormArray; + const keyControl = this.fb.control(dataKey, []); + keysArray.push(keyControl); + } + + private prepareKeysFormArray(keys: DataKey[] | undefined): UntypedFormArray { + const keysControls: Array = []; + if (keys) { + keys.forEach((key) => { + keysControls.push(this.fb.control(key, [])); + }); + } + return this.fb.array(keysControls); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.html new file mode 100644 index 0000000000..4c2742173b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.html @@ -0,0 +1,136 @@ + + + + + + +
+
widget-config.appearance
+
+ + {{ 'widget-config.title' | translate }} + +
+ + + + + + + +
+
+
+ + {{ 'widgets.value-card.icon' | translate }} + +
+ + + + + + + + +
+
+
+ + {{ 'widgets.aggregated-value-card.subtitle' | translate }} + +
+ + + + + + + +
+
+
+ + {{ 'widgets.value-card.date' | translate }} + +
+ + + + + +
+
+
+ + {{ 'widgets.aggregated-value-card.chart' | translate }} + + + +
+
+ + +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widget-config.show-card-buttons
+ + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts new file mode 100644 index 0000000000..65003ebb03 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts @@ -0,0 +1,291 @@ +/// +/// Copyright © 2016-2023 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, Injector } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { DataKey, Datasource, WidgetConfig, } from '@shared/models/widget.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { + getTimewindowConfig, + setTimewindowConfig +} from '@home/components/widget/config/timewindow-config-panel.component'; +import { isUndefined } from '@core/utils'; +import { + cssSizeToStrSize, + DateFormatProcessor, + DateFormatSettings, getDataKey, + resolveCssSize +} from '@shared/models/widget-settings.models'; +import { + aggregatedValueCardDefaultSettings, + AggregatedValueCardWidgetSettings, + createDefaultAggregatedValueLatestDataKeys +} from '@home/components/widget/lib/cards/aggregated-value-card.models'; +import { + AggregationType, + HistoryWindowType, + HOUR, + QuickTimeInterval, + TimewindowType +} from '@shared/models/time/time.models'; + +@Component({ + selector: 'tb-aggregated-value-card-basic-config', + templateUrl: './aggregated-value-card-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class AggregatedValueCardBasicConfigComponent extends BasicWidgetConfigComponent { + + public get datasource(): Datasource { + const datasources: Datasource[] = this.aggregatedValueCardWidgetConfigForm.get('datasources').value; + if (datasources && datasources.length) { + return datasources[0]; + } else { + return null; + } + } + + public get keyName(): string { + const dataKey = getDataKey(this.aggregatedValueCardWidgetConfigForm.get('datasources').value); + if (dataKey) { + return dataKey.name; + } else { + return null; + } + } + + aggregatedValueCardWidgetConfigForm: UntypedFormGroup; + + datePreviewFn = this._datePreviewFn.bind(this); + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private cd: ChangeDetectorRef, + private $injector: Injector, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.aggregatedValueCardWidgetConfigForm; + } + + protected setupDefaults(configData: WidgetConfigComponentData) { + this.setupDefaultDatasource(configData, [ + { name: 'watermeter', label: 'Watermeter', type: DataKeyType.timeseries } + ], + createDefaultAggregatedValueLatestDataKeys('watermeter', 'm³') + ); + configData.config.useDashboardTimewindow = false; + configData.config.displayTimewindow = true; + configData.config.timewindow = { + selectedTab: TimewindowType.HISTORY, + history: { + historyType: HistoryWindowType.INTERVAL, + quickInterval: QuickTimeInterval.CURRENT_MONTH_SO_FAR, + }, + aggregation: { + type: AggregationType.AVG, + interval: 12 * HOUR, + limit: 5000 + } + }; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: AggregatedValueCardWidgetSettings = {...aggregatedValueCardDefaultSettings, ...(configData.config.settings || {})}; + const iconSize = resolveCssSize(configData.config.iconSize); + this.aggregatedValueCardWidgetConfigForm = this.fb.group({ + timewindowConfig: [getTimewindowConfig(configData.config), []], + datasources: [configData.config.datasources, []], + + showTitle: [configData.config.showTitle, []], + title: [configData.config.title, []], + titleFont: [configData.config.titleFont, []], + titleColor: [configData.config.titleColor, []], + + showIcon: [configData.config.showTitleIcon, []], + iconSize: [iconSize[0], [Validators.min(0)]], + iconSizeUnit: [iconSize[1], []], + icon: [configData.config.titleIcon, []], + iconColor: [configData.config.iconColor, []], + + showSubtitle: [settings.showSubtitle, []], + subtitle: [settings.subtitle, []], + subtitleFont: [settings.subtitleFont, []], + subtitleColor: [settings.subtitleColor, []], + + showDate: [settings.showDate, []], + dateFormat: [settings.dateFormat, []], + dateFont: [settings.dateFont, []], + dateColor: [settings.dateColor, []], + + showChart: [settings.showChart, []], + chartColor: [settings.chartColor, []], + + values: [this.getValues(configData.config.datasources), []], + + background: [settings.background, []], + + cardButtons: [this.getCardButtons(configData.config), []], + borderRadius: [configData.config.borderRadius, []], + + actions: [configData.config.actions || {}, []] + }); + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); + this.widgetConfig.config.datasources = config.datasources; + + this.widgetConfig.config.showTitle = config.showTitle; + this.widgetConfig.config.title = config.title; + this.widgetConfig.config.titleFont = config.titleFont; + this.widgetConfig.config.titleColor = config.titleColor; + + this.widgetConfig.config.showTitleIcon = config.showIcon; + this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit); + this.widgetConfig.config.titleIcon = config.icon; + this.widgetConfig.config.iconColor = config.iconColor; + + this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; + + this.widgetConfig.config.settings.showSubtitle = config.showSubtitle; + this.widgetConfig.config.settings.subtitle = config.subtitle; + this.widgetConfig.config.settings.subtitleFont = config.subtitleFont; + this.widgetConfig.config.settings.subtitleColor = config.subtitleColor; + + this.widgetConfig.config.settings.showDate = config.showDate; + this.widgetConfig.config.settings.dateFormat = config.dateFormat; + this.widgetConfig.config.settings.dateFont = config.dateFont; + this.widgetConfig.config.settings.dateColor = config.dateColor; + + this.widgetConfig.config.settings.showChart = config.showChart; + this.widgetConfig.config.settings.chartColor = config.chartColor; + + this.setValues(config.values, this.widgetConfig.config.datasources); + + this.widgetConfig.config.settings.background = config.background; + + this.setCardButtons(config.cardButtons, this.widgetConfig.config); + this.widgetConfig.config.borderRadius = config.borderRadius; + + this.widgetConfig.config.actions = config.actions; + return this.widgetConfig; + } + + protected validatorTriggers(): string[] { + return ['showTitle', 'showIcon', 'showSubtitle', 'showDate', 'showChart']; + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + const showTitle: boolean = this.aggregatedValueCardWidgetConfigForm.get('showTitle').value; + const showIcon: boolean = this.aggregatedValueCardWidgetConfigForm.get('showIcon').value; + const showSubtitle: boolean = this.aggregatedValueCardWidgetConfigForm.get('showSubtitle').value; + const showDate: boolean = this.aggregatedValueCardWidgetConfigForm.get('showDate').value; + const showChart: boolean = this.aggregatedValueCardWidgetConfigForm.get('showChart').value; + + if (showTitle) { + this.aggregatedValueCardWidgetConfigForm.get('title').enable(); + this.aggregatedValueCardWidgetConfigForm.get('titleFont').enable(); + this.aggregatedValueCardWidgetConfigForm.get('titleColor').enable(); + this.aggregatedValueCardWidgetConfigForm.get('showIcon').enable({emitEvent: false}); + if (showIcon) { + this.aggregatedValueCardWidgetConfigForm.get('iconSize').enable(); + this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').enable(); + this.aggregatedValueCardWidgetConfigForm.get('icon').enable(); + this.aggregatedValueCardWidgetConfigForm.get('iconColor').enable(); + } else { + this.aggregatedValueCardWidgetConfigForm.get('iconSize').disable(); + this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').disable(); + this.aggregatedValueCardWidgetConfigForm.get('icon').disable(); + this.aggregatedValueCardWidgetConfigForm.get('iconColor').disable(); + } + } else { + this.aggregatedValueCardWidgetConfigForm.get('title').disable(); + this.aggregatedValueCardWidgetConfigForm.get('titleFont').disable(); + this.aggregatedValueCardWidgetConfigForm.get('titleColor').disable(); + this.aggregatedValueCardWidgetConfigForm.get('showIcon').disable({emitEvent: false}); + this.aggregatedValueCardWidgetConfigForm.get('iconSize').disable(); + this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').disable(); + this.aggregatedValueCardWidgetConfigForm.get('icon').disable(); + this.aggregatedValueCardWidgetConfigForm.get('iconColor').disable(); + } + + if (showSubtitle) { + this.aggregatedValueCardWidgetConfigForm.get('subtitle').enable(); + this.aggregatedValueCardWidgetConfigForm.get('subtitleFont').enable(); + this.aggregatedValueCardWidgetConfigForm.get('subtitleColor').enable(); + } else { + this.aggregatedValueCardWidgetConfigForm.get('subtitle').disable(); + this.aggregatedValueCardWidgetConfigForm.get('subtitleFont').disable(); + this.aggregatedValueCardWidgetConfigForm.get('subtitleColor').disable(); + } + + if (showDate) { + this.aggregatedValueCardWidgetConfigForm.get('dateFormat').enable(); + this.aggregatedValueCardWidgetConfigForm.get('dateFont').enable(); + this.aggregatedValueCardWidgetConfigForm.get('dateColor').enable(); + } else { + this.aggregatedValueCardWidgetConfigForm.get('dateFormat').disable(); + this.aggregatedValueCardWidgetConfigForm.get('dateFont').disable(); + this.aggregatedValueCardWidgetConfigForm.get('dateColor').disable(); + } + + if (showChart) { + this.aggregatedValueCardWidgetConfigForm.get('chartColor').enable(); + } else { + this.aggregatedValueCardWidgetConfigForm.get('chartColor').disable(); + } + } + + private getValues(datasources?: Datasource[]): DataKey[] { + if (datasources && datasources.length) { + return datasources[0].latestDataKeys || []; + } + return []; + } + + private setValues(values: DataKey[], datasources?: Datasource[]) { + if (datasources && datasources.length) { + datasources[0].latestDataKeys = values; + } + } + + private getCardButtons(config: WidgetConfig): string[] { + const buttons: string[] = []; + if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { + buttons.push('fullscreen'); + } + return buttons; + } + + private setCardButtons(buttons: string[], config: WidgetConfig) { + config.enableFullscreen = buttons.includes('fullscreen'); + } + + private _datePreviewFn(): string { + const dateFormat: DateFormatSettings = this.aggregatedValueCardWidgetConfigForm.get('dateFormat').value; + const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat); + processor.update(Date.now()); + return processor.formatted; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.html index 19ad94d76d..68ef7e4c63 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.html @@ -45,6 +45,7 @@ [widgetType]="data.widgetType" [showPostProcessing]="data.showPostProcessing" [callbacks]="data.callbacks" + [hideDataKeyName]="data.hideDataKeyName" [hideDataKeyLabel]="data.hideDataKeyLabel" [hideDataKeyColor]="data.hideDataKeyColor" [hideDataKeyUnits]="data.hideDataKeyUnits" diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts index 2b3f67a296..f98fb097a0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts @@ -50,10 +50,11 @@ export interface DataKeyConfigDialogData { entityAliasId?: string; showPostProcessing?: boolean; callbacks?: DataKeysCallbacks; - hideDataKeyLabel: boolean; - hideDataKeyColor: boolean; - hideDataKeyUnits: boolean; - hideDataKeyDecimals: boolean; + hideDataKeyName?: boolean; + hideDataKeyLabel?: boolean; + hideDataKeyColor?: boolean; + hideDataKeyUnits?: boolean; + hideDataKeyDecimals?: boolean; } @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html index 4a4a2c4d90..e265ea01d3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html @@ -20,7 +20,7 @@
datakey.general
- + {{ 'entity.key' | translate }}
+
+ + {{ 'timewindow.displayTypePrefix' | translate }} + +
timewindow.preview
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts index e0e4ee5615..85e7e5a4be 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts @@ -63,7 +63,8 @@ export class TimewindowStylePanelComponent extends PageComponent implements OnIn icon: [computedTimewindowStyle.icon, []], iconPosition: [computedTimewindowStyle.iconPosition, []], font: [computedTimewindowStyle.font, []], - color: [computedTimewindowStyle.color, []] + color: [computedTimewindowStyle.color, []], + displayTypePrefix: [computedTimewindowStyle.displayTypePrefix, []] } ); this.updatePreviewStyle(this.timewindowStyle); diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts index 9bda9345dc..fb5dd252b6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts @@ -119,7 +119,7 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement return this.configForm().valid; } - protected setupDefaultDatasource(configData: WidgetConfigComponentData, keys?: DataKey[]) { + protected setupDefaultDatasource(configData: WidgetConfigComponentData, keys?: DataKey[], latestKeys?: DataKey[]) { let datasources = configData.config.datasources; if (!datasources || !datasources.length) { datasources = [ @@ -135,23 +135,58 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement dataKeys = []; datasources[0].dataKeys = dataKeys; } + let latestDataKeys = datasources[0].latestDataKeys; + if (!latestDataKeys) { + latestDataKeys = []; + datasources[0].latestDataKeys = latestDataKeys; + } if (keys && keys.length) { dataKeys.length = 0; keys.forEach(key => { - const dataKey = - this.widgetConfigComponent.widgetConfigCallbacks.generateDataKey(key.name, key.type, configData.dataKeySettingsSchema); - if (key.label) { - dataKey.label = key.label; - } - if (key.units) { - dataKey.units = key.units; - } - if (isDefinedAndNotNull(key.decimals)) { - dataKey.decimals = key.decimals; - } + const dataKey = this.constructDataKey(configData, key); dataKeys.push(dataKey); }); } + if (latestKeys && latestKeys.length) { + latestDataKeys.length = 0; + latestKeys.forEach(key => { + const dataKey = this.constructDataKey(configData, key); + latestDataKeys.push(dataKey); + }); + } + } + + protected constructDataKey(configData: WidgetConfigComponentData, key: DataKey): DataKey { + const dataKey = + this.widgetConfigComponent.widgetConfigCallbacks.generateDataKey(key.name, key.type, configData.dataKeySettingsSchema); + if (key.label) { + dataKey.label = key.label; + } + if (key.units) { + dataKey.units = key.units; + } + if (isDefinedAndNotNull(key.decimals)) { + dataKey.decimals = key.decimals; + } + if (isDefinedAndNotNull(key.settings)) { + dataKey.settings = key.settings; + } + if (isDefinedAndNotNull(key.aggregationType)) { + dataKey.aggregationType = key.aggregationType; + } + if (isDefinedAndNotNull(key.comparisonEnabled)) { + dataKey.comparisonEnabled = key.comparisonEnabled; + } + if (isDefinedAndNotNull(key.timeForComparison)) { + dataKey.timeForComparison = key.timeForComparison; + } + if (isDefinedAndNotNull(key.comparisonCustomIntervalValue)) { + dataKey.comparisonCustomIntervalValue = key.comparisonCustomIntervalValue; + } + if (isDefinedAndNotNull(key.comparisonResultType)) { + dataKey.comparisonResultType = key.comparisonResultType; + } + return dataKey; } protected abstract configForm(): UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html new file mode 100644 index 0000000000..b20431fa64 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html @@ -0,0 +1,90 @@ + +
+
+
+ + +
+ + + +
+ + +
{{ subtitle$ | async }}
+
+ +
+
+
+ + + + + + +
+
+ + + +
+
+ + + + + + +
+
+
+
+ +
+
+
{{tickMax$ | async}}
+
{{tickMin$ | async}}
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ + arrow_upward + arrow_downward +
+
+ {{ value.value }} + {{ value.units }} +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss new file mode 100644 index 0000000000..6dee828c36 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss @@ -0,0 +1,156 @@ +/** + * Copyright © 2016-2023 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. + */ +:host { + .tb-aggregated-value-card-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 20px 24px 24px; + > div:not(.tb-value-card-overlay) { + z-index: 1; + } + .tb-aggregated-value-card-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + > div.tb-aggregated-value-card-title-panel { + display: flex; + flex-direction: column; + .tb-aggregated-value-card-subtitle { + margin-left: 28px; + } + } + .tb-aggregated-value-card-values, .tb-aggregated-value-card-chart { + flex: 1; + min-height: 0; + overflow: hidden; + } + .tb-aggregated-value-card-values-container { + width: 100%; + height: 100%; + padding: 8px 0; + display: grid; + grid-template-columns: minmax(0, 1fr) fit-content(100%) minmax(0, 1fr); + .tb-aggregated-value-card-values-section { + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + &.left { + align-items: flex-start; + } + &.center { + align-items: center; + } + &.right { + align-items: flex-end; + } + } + } + .tb-aggregated-value-card-chart { + display: flex; + gap: 8px; + flex-direction: row; + .tb-aggregated-value-card-chart-ticks { + height: 100%; + display: flex; + flex-direction: column; + place-content: flex-end space-between; + align-items: flex-end; + font-size: 11px; + line-height: 16px; + font-weight: 400; + color: rgba(0, 0, 0, 0.38); + } + .tb-aggregated-value-card-chart-container { + position: relative; + flex: 1; + margin-top: 8px; + margin-bottom: 8px; + .tb-aggregated-value-card-chart-element { + width: 100%; + height: 100%; + } + .tb-aggregated-value-card-chart-boundary { + position: absolute; + width: 6px; + height: 6px; + &.top { + top: 0; + border-top: 2px solid rgba(0,0,0,0.38); + } + &.left { + left: 0; + border-left: 2px solid rgba(0,0,0,0.38); + } + &.right { + right: 0; + border-right: 2px solid rgba(0,0,0,0.38); + } + &.bottom { + bottom: 0; + border-bottom: 2px solid rgba(0,0,0,0.38); + } + } + } + } + .tb-aggregated-value-card-value { + white-space: nowrap; + min-height: 0; + display: flex; + flex-direction: row; + place-content: center; + align-items: center; + .value-arrow-container { + display: flex; + } + .value-text { + line-height: 1; + } + .value-arrow { + font-size: 1.1em; + height: 1.1em; + line-height: 1.1em; + min-width: 1em; + width: 1em; + } + .units { + font-size: 85%; + padding-left: 0.2em; + &.small { + font-size: 50%; + } + } + } + } +} + +:host ::ng-deep { + .tb-aggregated-value-card-panel { + > div.tb-aggregated-value-card-title-panel { + .tb-widget-title { + padding: 0; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts new file mode 100644 index 0000000000..95f605e7be --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts @@ -0,0 +1,260 @@ +/// +/// Copyright © 2016-2023 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, + OnInit, + TemplateRef, + ViewChild +} from '@angular/core'; +import { + aggregatedValueCardDefaultSettings, + AggregatedValueCardKeyPosition, + AggregatedValueCardValue, + AggregatedValueCardWidgetSettings, + computeAggregatedCardValue, + getTsValueByLatestDataKey +} from '@home/components/widget/lib/cards/aggregated-value-card.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { Observable } from 'rxjs'; +import { + backgroundStyle, + ColorProcessor, + ComponentStyle, + DateFormatProcessor, getDataKey, + getLatestSingleTsValue, + overlayStyle, + textStyle +} from '@shared/models/widget-settings.models'; +import { DatePipe } from '@angular/common'; +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 { formatNumberValue, formatValue, isDefined, isNumeric } from '@core/utils'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'tb-aggregated-value-card-widget', + templateUrl: './aggregated-value-card-widget.component.html', + styleUrls: ['./aggregated-value-card-widget.component.scss'] +}) +export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit { + + @ViewChild('chartElement', {static: false}) chartElement: ElementRef; + + aggregatedValueCardKeyPosition = AggregatedValueCardKeyPosition; + + settings: AggregatedValueCardWidgetSettings; + + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + + showSubtitle = true; + subtitle$: Observable; + subtitleStyle: ComponentStyle = {}; + subtitleColor: ColorProcessor; + + showValues = false; + + values: {[key: string]: AggregatedValueCardValue} = {}; + + showChart = true; + chartColor: ColorProcessor; + + showDate = true; + dateFormat: DateFormatProcessor; + dateStyle: ComponentStyle = {}; + dateColor: ColorProcessor; + + backgroundStyle: ComponentStyle = {}; + overlayStyle: ComponentStyle = {}; + + private flot: TbFlot; + private flotDataKey: DataKey; + + private lastUpdateTs: number; + + tickMin$: Observable; + tickMax$: Observable; + + constructor(private date: DatePipe, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + this.ctx.$scope.aggregatedValueCardWidget = this; + this.settings = {...aggregatedValueCardDefaultSettings, ...this.ctx.settings}; + this.showSubtitle = this.settings.showSubtitle; + const subtitle = this.settings.subtitle; + this.subtitle$ = this.ctx.registerLabelPattern(subtitle, this.subtitle$); + this.subtitleStyle = textStyle(this.settings.subtitleFont, '0.25px'); + this.subtitleColor = ColorProcessor.fromSettings(this.settings.subtitleColor); + + const dataKey = getDataKey(this.ctx.defaultSubscription.datasources); + if (dataKey?.name && this.ctx.defaultSubscription.firstDatasource?.latestDataKeys?.length) { + const dataKeys = this.ctx.defaultSubscription.firstDatasource?.latestDataKeys; + for (const position of Object.keys(AggregatedValueCardKeyPosition)) { + const value = computeAggregatedCardValue(dataKeys, dataKey?.name, AggregatedValueCardKeyPosition[position]); + if (value) { + this.values[position] = value; + } + } + this.showValues = !!Object.keys(this.values).length; + } + + this.showChart = this.settings.showChart; + this.chartColor = ColorProcessor.fromSettings(this.settings.chartColor); + if (this.showChart) { + if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) { + this.flotDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0]; + this.flotDataKey.settings = { + fillLines: false, + showLines: true, + lineWidth: 2 + } as TbFlotKeySettings; + this.flotDataKey.color = this.chartColor.color; + } + } + + this.showDate = this.settings.showDate; + this.dateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.dateFormat); + this.dateStyle = textStyle(this.settings.dateFont, '0.25px'); + this.dateColor = ColorProcessor.fromSettings(this.settings.dateColor); + + this.backgroundStyle = backgroundStyle(this.settings.background); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + } + + ngAfterViewInit(): void { + if (this.showChart) { + const settings = { + shadowSize: 0, + smoothLines: false, + grid: { + tickColor: 'rgba(0,0,0,0.12)', + horizontalLines: true, + verticalLines: false, + outlineWidth: 0, + minBorderMargin: 0, + margin: 0 + }, + yaxis: { + showLabels: false, + tickGenerator: 'return [(axis.max + axis.min) / 2];' + }, + xaxis: { + showLabels: false + } + } as TbFlotSettings; + this.flot = new TbFlot(this.ctx, 'line', $(this.chartElement.nativeElement), settings); + this.tickMin$ = this.flot.yMin$.pipe( + map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals), + (this.flotDataKey?.units || this.ctx.units)) + )); + this.tickMax$ = this.flot.yMax$.pipe( + map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals), + (this.flotDataKey?.units || this.ctx.units)) + )); + } + } + + public onInit() { + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + + public onDataUpdated() { + const tsValue = getLatestSingleTsValue(this.ctx.data); + let ts; + let value; + if (tsValue) { + ts = tsValue[0]; + value = tsValue[1]; + } + this.subtitleColor.update(value); + this.dateColor.update(value); + + if (this.showChart) { + this.chartColor.update(value); + this.flot.updateSeriesColor(this.chartColor.color); + this.flot.update(); + } + + this.updateLastUpdateTs(ts); + this.cd.detectChanges(); + } + + public onLatestDataUpdated() { + if (this.showValues) { + for (const aggValue of Object.values(this.values)) { + const tsValue = getTsValueByLatestDataKey(this.ctx.latestData, aggValue.key); + let ts; + let value; + if (tsValue) { + ts = tsValue[0]; + value = tsValue[1]; + aggValue.value = formatValue(value, (aggValue.key.decimals || this.ctx.decimals), null, false); + } else { + aggValue.value = 'N/A'; + } + const numeric = formatNumberValue(value, (aggValue.key.decimals || this.ctx.decimals)); + aggValue.color.update(numeric); + if (aggValue.showArrow && isDefined(numeric)) { + aggValue.upArrow = numeric > 0; + aggValue.downArrow = numeric < 0; + } else { + aggValue.upArrow = aggValue.downArrow = false; + } + this.updateLastUpdateTs(ts); + } + this.cd.detectChanges(); + } + } + + public onResize() { + if (this.showChart) { + this.flot.resize(); + } + } + + public onEditModeChanged() { + if (this.showChart) { + this.flot.checkMouseEvents(); + } + } + + public onDestroy() { + if (this.showChart) { + this.flot.destroy(); + } + } + + private updateLastUpdateTs(ts: number) { + if (ts && (!this.lastUpdateTs || ts > this.lastUpdateTs)) { + this.lastUpdateTs = ts; + this.dateFormat.update(ts); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts new file mode 100644 index 0000000000..273ca1edaa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts @@ -0,0 +1,232 @@ +/// +/// Copyright © 2016-2023 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 { + BackgroundSettings, + BackgroundType, + ColorProcessor, + ColorSettings, + ColorType, + ComponentStyle, + constantColor, + DateFormatSettings, + Font, + iconStyle, + lastUpdateAgoDateFormat, + textStyle +} from '@shared/models/widget-settings.models'; +import { ComparisonResultType, DataKey, DatasourceData } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { AggregationType } from '@shared/models/time/time.models'; + +export interface AggregatedValueCardWidgetSettings { + showSubtitle: boolean; + subtitle: string; + subtitleFont: Font; + subtitleColor: ColorSettings; + showDate: boolean; + dateFormat: DateFormatSettings; + dateFont: Font; + dateColor: ColorSettings; + showChart: boolean; + chartColor: ColorSettings; + background: BackgroundSettings; +} + +export enum AggregatedValueCardKeyPosition { + center = 'center', + rightTop = 'rightTop', + rightBottom = 'rightBottom', + leftTop = 'leftTop', + leftBottom = 'leftBottom' +} + +export const aggregatedValueCardKeyPositionTranslations = new Map( + [ + [AggregatedValueCardKeyPosition.center, 'widgets.aggregated-value-card.position-center'], + [AggregatedValueCardKeyPosition.rightTop, 'widgets.aggregated-value-card.position-right-top'], + [AggregatedValueCardKeyPosition.rightBottom, 'widgets.aggregated-value-card.position-right-bottom'], + [AggregatedValueCardKeyPosition.leftTop, 'widgets.aggregated-value-card.position-left-top'], + [AggregatedValueCardKeyPosition.leftBottom, 'widgets.aggregated-value-card.position-left-bottom'] + ] +); + +export interface AggregatedValueCardKeySettings { + position: AggregatedValueCardKeyPosition; + font: Font; + color: ColorSettings; + showArrow: boolean; +} + +export interface AggregatedValueCardValue { + key: DataKey; + value: string; + units: string; + style: ComponentStyle; + color: ColorProcessor; + center: boolean; + showArrow: boolean; + upArrow: boolean; + downArrow: boolean; +} + +export const computeAggregatedCardValue = (dataKeys: DataKey[], keyName: string, position: AggregatedValueCardKeyPosition): AggregatedValueCardValue => { + const key = dataKeys.find(dataKey => ( dataKey.name === keyName && (dataKey.settings?.position === position || + (!dataKey.settings?.position && position === AggregatedValueCardKeyPosition.center)) )); + if (key) { + const settings: AggregatedValueCardKeySettings = key.settings; + return { + key, + value: '', + units: key.units, + style: textStyle(settings.font, '0.25px'), + color: ColorProcessor.fromSettings(settings.color), + center: position === AggregatedValueCardKeyPosition.center, + showArrow: settings.showArrow, + upArrow: false, + downArrow: false + }; + } +}; + +export const getTsValueByLatestDataKey = (latestData: Array, dataKey: DataKey): [number, any] => { + if (latestData?.length) { + const dsData = latestData.find(data => data.dataKey === dataKey); + if (dsData?.data?.length) { + return dsData.data[0]; + } + } + return null; +}; + +export const aggregatedValueCardDefaultSettings: AggregatedValueCardWidgetSettings = { + showSubtitle: true, + subtitle: '${entityName}', + subtitleFont: { + family: 'Roboto', + size: 12, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '16px' + }, + subtitleColor: constantColor('rgba(0, 0, 0, 0.38)'), + showDate: true, + dateFormat: lastUpdateAgoDateFormat(), + dateFont: { + family: 'Roboto', + size: 12, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '16px' + }, + dateColor: constantColor('rgba(0, 0, 0, 0.38)'), + showChart: true, + chartColor: constantColor('rgba(0, 0, 0, 0.87)'), + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + } +}; + +export const aggregatedValueCardDefaultKeySettings: AggregatedValueCardKeySettings = { + position: AggregatedValueCardKeyPosition.center, + font: { + family: 'Roboto', + size: 14, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '1' + }, + color: constantColor('rgba(0, 0, 0, 0.87)'), + showArrow: false +}; + +export const createDefaultAggregatedValueLatestDataKeys = (keyName: string, units): DataKey[] => [ + { + name: keyName, label: keyName, type: DataKeyType.timeseries, units, decimals: 0, + aggregationType: AggregationType.NONE, + settings: { + position: AggregatedValueCardKeyPosition.center, + font: { + family: 'Roboto', + size: 52, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '1' + }, + color: constantColor('rgba(0, 0, 0, 0.87)'), + showArrow: false + } as AggregatedValueCardKeySettings + }, + { + name: keyName, label: 'Delta percent ' + keyName, type: DataKeyType.timeseries, units: '%', decimals: 0, + aggregationType: AggregationType.AVG, + comparisonEnabled: true, + timeForComparison: 'previousInterval', + comparisonResultType: ComparisonResultType.DELTA_PERCENT, + settings: { + position: AggregatedValueCardKeyPosition.rightTop, + font: { + family: 'Roboto', + size: 14, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '1' + }, + color: { + color: 'rgba(0, 0, 0, 0.87)', + type: ColorType.range, + rangeList: [ + {to: 0, color: '#198038'}, + {from: 0, to: 0, color: 'rgba(0, 0, 0, 0.87)'}, + {from: 0, color: '#D12730'} + ], + colorFunction: '' + }, + showArrow: true + } as AggregatedValueCardKeySettings + }, + { + name: keyName, label: 'Delta absolute ' + keyName, type: DataKeyType.timeseries, units, decimals: 1, + aggregationType: AggregationType.AVG, + comparisonEnabled: true, + timeForComparison: 'previousInterval', + comparisonResultType: ComparisonResultType.DELTA_ABSOLUTE, + settings: { + position: AggregatedValueCardKeyPosition.rightBottom, + font: { + family: 'Roboto', + size: 11, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '1' + }, + color: constantColor('rgba(0, 0, 0, 0.38)'), + showArrow: false + } as AggregatedValueCardKeySettings + } + ]; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html index fee7dc5259..377e0a31f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html @@ -66,7 +66,7 @@
{{ label$ | async }}
-
{{ dateFormat.formatted }}
+
{{ valueText }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts index 64267a5637..907878368d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts @@ -83,7 +83,7 @@ export function loadNodeCtxFunction any>(functionB } export function materialIconHtml(materialIcon: string): string { - return '' + materialIcon + ''; + return '' + materialIcon + ''; } export function iconUrlHtml(iconUrl: string): string { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts index 844d7540ff..7faa9e9de3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts @@ -132,6 +132,7 @@ export interface TbFlotYAxisSettings { ticksFormatter: string; tickDecimals: number; tickSize: number; + tickGenerator: string; } export interface TbFlotBaseSettings { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts index 1d20068e20..e38bb3b098 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts @@ -18,7 +18,8 @@ import { WidgetContext } from '@home/models/widget-component.models'; import { createLabelFromDatasource, - deepClone, formattedDataFormDatasourceData, + deepClone, + formattedDataFormDatasourceData, insertVariable, isDefined, isDefinedAndNotNull, @@ -59,6 +60,7 @@ import { AggregationType } from '@shared/models/time/time.models'; import { CancelAnimationFrame } from '@core/services/raf.service'; import { UtilsService } from '@core/services/utils.service'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { BehaviorSubject } from 'rxjs'; import Timeout = NodeJS.Timeout; const moment = moment_; @@ -130,9 +132,15 @@ export class TbFlot { private pieAnimationLastTime: number; private pieAnimationCaf: CancelAnimationFrame; - constructor(private ctx: WidgetContext, private readonly chartType: ChartType, private $flotElement?: JQuery) { + private yMinSubject = new BehaviorSubject(-1); + private yMaxSubject = new BehaviorSubject(1); + + yMin$ = this.yMinSubject.asObservable(); + yMax$ = this.yMaxSubject.asObservable(); + + constructor(private ctx: WidgetContext, private readonly chartType: ChartType, private $flotElement?: JQuery, settings?: TbFlotSettings) { this.chartType = this.chartType || 'line'; - this.settings = ctx.settings as TbFlotSettings; + this.settings = settings || (ctx.settings as TbFlotSettings); this.utils = this.ctx.$injector.get(UtilsService); this.enableSelection = isDefined(this.settings.enableSelection) ? this.settings.enableSelection : true; this.selectionMode = this.enableSelection ? 'x' : null; @@ -209,6 +217,12 @@ export class TbFlot { } else { this.yaxis.tickSize = null; } + if (this.settings.yaxis.tickGenerator?.length) { + try { + this.yaxis.ticks = new Function('axis', + this.settings.yaxis.tickGenerator); + } catch (e) {} + } if (isNumber(this.settings.yaxis.tickDecimals)) { this.yaxis.tickDecimals = this.settings.yaxis.tickDecimals; } else { @@ -717,6 +731,15 @@ export class TbFlot { } } + public updateSeriesColor(color: string) { + if (this.subscription?.data?.length) { + const series = this.subscription.data[0] as TbFlotSeries; + series.dataKey.color = color; + series.color = color; + series.highlightColor = tinycolor(color).setAlpha(.75).toRgbString(); + } + } + private latestDataByDataIndex(index: number): FormattedData { if (this.latestData[index]) { return this.latestData[index]; @@ -802,6 +825,8 @@ export class TbFlot { clearTimeout(this.resizeTimeoutHandle); this.resizeTimeoutHandle = null; } + this.yMinSubject.complete(); + this.yMaxSubject.complete(); } private createPlot() { @@ -818,6 +843,7 @@ export class TbFlot { } else { this.plot = $.plot(this.$element, this.subscription.data, this.options) as JQueryPlot; } + this.updateYMinMax(); } else { this.createPlotTimeoutHandle = setTimeout(this.createPlot.bind(this), 30); } @@ -830,6 +856,20 @@ export class TbFlot { this.plot.setupGrid(); } this.plot.draw(); + this.updateYMinMax(); + } + + private updateYMinMax() { + if (this.plot?.getYAxes().length) { + const min = this.plot?.getYAxes()[0].min; + const max = this.plot?.getYAxes()[0].max; + if (this.yMinSubject.value !== min) { + this.yMinSubject.next(min); + } + if (this.yMaxSubject.value !== max) { + this.yMaxSubject.next(max); + } + } } private redrawPlot() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html new file mode 100644 index 0000000000..32d48347ac --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html @@ -0,0 +1,48 @@ + + +
+
widgets.aggregated-value-card.value-appearance
+
+
{{ 'widgets.aggregated-value-card.position' | translate }}
+ + + + {{ aggregatedValueCardKeyPositionTranslationMap.get(position) | translate }} + + + +
+
+
{{ 'widgets.aggregated-value-card.font' | translate }}
+ + +
+
+
{{ 'widgets.aggregated-value-card.color' | translate }}
+ + +
+
+ + {{ 'widgets.aggregated-value-card.display-up-down-arrow' | translate }} + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts new file mode 100644 index 0000000000..e527c3b3d4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts @@ -0,0 +1,64 @@ +/// +/// Copyright © 2016-2023 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 } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + aggregatedValueCardDefaultKeySettings, + AggregatedValueCardKeyPosition, + aggregatedValueCardKeyPositionTranslations +} from '@home/components/widget/lib/cards/aggregated-value-card.models'; +import { constantColor } from '@shared/models/widget-settings.models'; + +@Component({ + selector: 'tb-aggregated-value-card-key-settings', + templateUrl: './aggregated-value-card-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class AggregatedValueCardKeySettingsComponent extends WidgetSettingsComponent { + + aggregatedValueCardKeyPositions: AggregatedValueCardKeyPosition[] = + Object.keys(AggregatedValueCardKeyPosition).map(value => AggregatedValueCardKeyPosition[value]); + + aggregatedValueCardKeyPositionTranslationMap = aggregatedValueCardKeyPositionTranslations; + + aggregatedValueCardKeySettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.aggregatedValueCardKeySettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...aggregatedValueCardDefaultKeySettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.aggregatedValueCardKeySettingsForm = this.fb.group({ + position: [settings.position, []], + font: [settings.font, []], + color: [settings.color, []], + showArrow: [settings.showArrow, []] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts index 46454937f6..9241f60dda 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts @@ -74,7 +74,8 @@ export const flotDefaultSettings = (chartType: ChartType): Partial - mdi:function-variant + mdi:function-variant
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts index 6d8ad1eefe..f92203459e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts @@ -99,7 +99,7 @@ export class ColorSettingsComponent implements OnInit, ControlValueAccessor { } private updateColorStyle() { - if (!this.disabled) { + if (!this.disabled && this.modelValue) { let colors: string[] = [this.modelValue.color]; if (this.modelValue.type === ColorType.range && this.modelValue.rangeList?.length) { const rangeColors = this.modelValue.rangeList.slice(0, Math.min(2, this.modelValue.rangeList.length)).map(r => r.color); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index 1e4010faaa..0f467ba917 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -267,6 +267,9 @@ import { ValueCardWidgetSettingsComponent } from '@home/components/widget/lib/settings/cards/value-card-widget-settings.component'; import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module'; +import { + AggregatedValueCardKeySettingsComponent +} from '@home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component'; @NgModule({ declarations: [ @@ -366,7 +369,8 @@ import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings TripAnimationWidgetSettingsComponent, DocLinksWidgetSettingsComponent, QuickLinksWidgetSettingsComponent, - ValueCardWidgetSettingsComponent + ValueCardWidgetSettingsComponent, + AggregatedValueCardKeySettingsComponent ], imports: [ CommonModule, @@ -471,7 +475,8 @@ import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings TripAnimationWidgetSettingsComponent, DocLinksWidgetSettingsComponent, QuickLinksWidgetSettingsComponent, - ValueCardWidgetSettingsComponent + ValueCardWidgetSettingsComponent, + AggregatedValueCardKeySettingsComponent ] }) export class WidgetSettingsModule { @@ -541,5 +546,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type ({ @@ -117,18 +119,44 @@ export const constantColor = (color: string): ColorSettings => ({ 'return \'blue\';' }); +export const cssSizeToStrSize = (size?: number, unit?: cssUnit): string => (isDefinedAndNotNull(size) ? size + '' : '0') + (unit || 'px'); + +export const resolveCssSize = (strSize?: string): [number, cssUnit] => { + if (!strSize || !strSize.trim().length) { + return [0, 'px']; + } + let resolvedUnit: cssUnit; + let resolvedSize = strSize; + for (const unit of cssUnits) { + if (strSize.endsWith(unit)) { + resolvedUnit = unit; + break; + } + } + if (resolvedUnit) { + resolvedSize = strSize.substring(0, strSize.length - resolvedUnit.length); + } + resolvedUnit = resolvedUnit || 'px'; + let numericSize = 0; + if (isNumeric(resolvedSize)) { + numericSize = Number(resolvedSize); + } + return [numericSize, resolvedUnit]; +}; + type ValueColorFunction = (value: any) => string; export abstract class ColorProcessor { static fromSettings(color: ColorSettings): ColorProcessor { - switch (color.type) { + const settings = color || constantColor('rgba(0, 0, 0, 0.87)'); + switch (settings.type) { case ColorType.constant: - return new ConstantColorProcessor(color); + return new ConstantColorProcessor(settings); case ColorType.range: - return new RangeColorProcessor(color); + return new RangeColorProcessor(settings); case ColorType.function: - return new FunctionColorProcessor(color); + return new FunctionColorProcessor(settings); } } @@ -164,13 +192,19 @@ class RangeColorProcessor extends ColorProcessor { if (this.settings.rangeList?.length && isDefinedAndNotNull(value) && isNumeric(value)) { const num = Number(value); for (const range of this.settings.rangeList) { - if ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)) { + if (this.constantRange(range) && range.from === num) { + return range.color; + } else if ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)) { return range.color; } } } return this.settings.color; } + + private constantRange(range: ColorRange): boolean { + return isNumber(range.from) && isNumber(range.to) && range.from === range.to; + } } class FunctionColorProcessor extends ColorProcessor { @@ -242,7 +276,7 @@ export abstract class DateFormatProcessor { } } - formatted = ''; + formatted = ' '; protected constructor(protected $injector: Injector, protected settings: DateFormatSettings) { @@ -412,3 +446,13 @@ export const getSingleTsValue = (data: Array): [number, any] => } return null; }; + +export const getLatestSingleTsValue = (data: Array): [number, any] => { + if (data.length) { + const dsData = data[0]; + if (dsData.data.length) { + return dsData.data[dsData.data.length - 1]; + } + } + return null; +}; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index f90a34fe8e..78728005de 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1208,7 +1208,11 @@ "delta-calculation-result-delta-absolute": "Delta (absolute)", "delta-calculation-result-delta-percent": "Delta (percent)", "source": "Source", - "latest": "Latest" + "latest": "Latest", + "latest-value": "Latest value", + "delta": "delta", + "percent": "percent", + "absolute": "absolute" }, "datasource": { "type": "Datasource type", @@ -3911,6 +3915,7 @@ "icon-position-right": "Right", "font": "Font", "color": "Color", + "displayTypePrefix": "Display Realtime/History prefix", "preview": "Preview" }, "unit": { @@ -5718,6 +5723,25 @@ "date": "Date", "value-card-style": "Value card style" }, + "aggregated-value-card": { + "subtitle": "Subtitle", + "chart": "Chart", + "values": "Values", + "value-appearance": "Value appearance", + "position": "Position", + "position-center": "Center", + "position-right-top": "Right top", + "position-right-bottom": "Right bottom", + "position-left-top": "Left top", + "position-left-bottom": "Left bottom", + "font": "Font", + "color": "Color", + "display-up-down-arrow": "Display Up/Down arrow", + "add-value": "Add value", + "remove-value": "Remove value", + "no-values": "No values configured", + "aggregation": "Aggregation" + }, "table": { "common-table-settings": "Common Table Settings", "enable-search": "Enable search", diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index bef8bf621e..7d255e6ccf 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -122,18 +122,6 @@ font: inherit; } } - .mat-slide { - margin: 0; - &.margin { - margin: 8px 0; - } - .mdc-form-field>label { - font-weight: 400; - font-size: 16px; - line-height: 24px; - margin-left: 12px; - } - } } .tb-form-panel-title { @@ -200,6 +188,21 @@ } } + .tb-form-panel, .tb-form-row { + .mat-slide { + margin: 0; + &.margin { + margin: 8px 0; + } + .mdc-form-field>label { + font-weight: 400; + font-size: 16px; + line-height: 24px; + margin-left: 12px; + } + } + } + .tb-form-row .mat-mdc-form-field, .mat-mdc-form-field.tb-inline-field { &.mat-form-field-appearance-fill { .mdc-text-field--filled:not(.mdc-text-field--disabled) {