diff --git a/ui-ngx/src/app/app.component.ts b/ui-ngx/src/app/app.component.ts index 5e579a759a..6e04735706 100644 --- a/ui-ngx/src/app/app.component.ts +++ b/ui-ngx/src/app/app.component.ts @@ -33,6 +33,7 @@ import { svgIcons, svgIconsUrl } from '@shared/models/icon.models'; import { ActionSettingsChangeLanguage } from '@core/settings/settings.actions'; import { SETTINGS_KEY } from '@core/settings/settings.effects'; import { initCustomJQueryEvents } from '@shared/models/jquery-event.models'; +import { UnitService } from '@core/services/unit/unit.service'; @Component({ selector: 'tb-root', @@ -46,7 +47,8 @@ export class AppComponent implements OnInit { private translate: TranslateService, private matIconRegistry: MatIconRegistry, private domSanitizer: DomSanitizer, - private authService: AuthService) { + private authService: AuthService, + private unitService: UnitService) { console.log(`ThingsBoard Version: ${env.tbVersion}`); @@ -94,12 +96,14 @@ export class AppComponent implements OnInit { this.store.select(selectUserReady).pipe( filter((data) => data.isUserLoaded), tap((data) => { - let userLang = getCurrentAuthState(this.store).userDetails?.additionalInfo?.lang ?? null; + const userDetails = getCurrentAuthState(this.store).userDetails; + let userLang = userDetails?.additionalInfo?.lang ?? null; if (!userLang && !data.isAuthenticated) { const settings = this.storageService.getItem(SETTINGS_KEY); userLang = settings?.userLang ?? null; } this.notifyUserLang(userLang); + this.unitService.setUnitSystem(userDetails?.additionalInfo?.unitSystem) }), skip(1), ).subscribe((data) => { diff --git a/ui-ngx/src/app/core/services/unit/converter-unit.ts b/ui-ngx/src/app/core/services/unit/converter-unit.ts new file mode 100644 index 0000000000..aa04521b55 --- /dev/null +++ b/ui-ngx/src/app/core/services/unit/converter-unit.ts @@ -0,0 +1,444 @@ +/// +/// Copyright © 2016-2025 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 { TbMeasure, TbUnitConvertor, Unit, UnitDescription, UnitSystem } from '@shared/models/unit.models'; +import { AllMeasures } from '@core/services/unit/definitions/all'; +import { TranslateService } from '@ngx-translate/core'; +import { isDefinedAndNotNull, isUndefinedOrNull } from '@core/utils'; + +export interface Conversion< + TMeasures extends string, + TSystems extends string, + TUnits extends string, +> { + abbr: TUnits; + measure: TMeasures; + system: TSystems; + unit: Unit; +} + +// export interface BestResult { +// val: number; +// unit: TUnits; +// name: string; +// tags: string[]; +// } + +type Entries = [S, T[keyof T]]; + +export type UnitCache = Map< + string, + { + system: TSystems; + measure: TMeasures; + unit: Unit; + abbr: TUnits; + } +>; + +export class Converter< + TMeasures extends AllMeasures, + TSystems extends UnitSystem, + TUnits extends string, +> { + private measureData: Record>; + private unitCache: Map< + string, + { + system: TSystems; + measure: TMeasures; + unit: Unit; + abbr: TUnits; + } + >; + + constructor( + measures: Record>, + unitCache: UnitCache + ) { + this.measureData = measures; + this.unitCache = unitCache; + } + + convertor(from: TUnits | (string & {}), to: TUnits | (string & {})): TbUnitConvertor{ + const origin = this.getUnit(from); + if (origin === null) { + throw Error(`Unsupported unit ${from}`); + } + const destination = this.getUnit(to); + if (destination === null) { + throw Error(`Unsupported unit ${from}`); + } + if (origin.abbr === destination.abbr) { + return (value: number) => value; + } + if (destination.measure !== origin.measure) { + throw Error(`Cannot convert incompatible measures of ${destination.measure} and ${origin.measure}`); + } + return (value: number): number => { + let result = value * origin.unit.to_anchor; + if (origin.unit.anchor_shift) { + result -= origin.unit.anchor_shift; + } + + if (origin.system !== destination.system) { + const measure = this.measureData[origin.measure]; + const anchors = measure.anchors; + if (!anchors) { + throw Error(`Unable to convert units. Anchors are missing for "${origin.measure}" and "${destination.measure}" measures.`); + } + + const anchor = anchors[origin.system]; + if (!anchor) { + throw Error(`Unable to convert units. Anchors are missing for "${origin.measure}" and "${destination.measure}" measures.`); + } + + const transform = anchor[destination.system]?.transform; + const ratio = anchor[destination.system]?.ratio; + + if (typeof transform === 'function') { + result = transform(result); + } else if (typeof ratio === 'number') { + result *= ratio; + } else { + throw Error('A system anchor needs to either have a defined ratio number or a transform function.'); + } + } + + if (destination.unit.anchor_shift) { + result += destination.unit.anchor_shift; + } + return result / destination.unit.to_anchor; + }; + } + + convert(value: number, from: TUnits | (string & {}), to: TUnits | (string & {})): number { + const origin = this.getUnit(from); + if (origin === null) { + throw Error(`Unsupported unit ${from}`); + } + const destination = this.getUnit(to); + if (destination === null) { + throw Error(`Unsupported unit ${from}`); + } + if (origin.abbr === destination.abbr) { + return value; + } + if (destination.measure !== origin.measure) { + throw Error(`Cannot convert incompatible measures of ${destination.measure} and ${origin.measure}`); + } + let result = value * origin.unit.to_anchor; + if (origin.unit.anchor_shift) { + result -= origin.unit.anchor_shift; + } + if (origin.system !== destination.system) { + const measure = this.measureData[origin.measure]; + const anchors = measure.anchors; + if (!anchors) { + throw Error(`Unable to convert units. Anchors are missing for "${origin.measure}" and "${destination.measure}" measures.`); + } + const anchor = anchors[origin.system]; + if (!anchor) { + throw Error(`Unable to convert units. Anchors are missing for "${origin.measure}" and "${destination.measure}" measures.`); + } + const transform = anchor[destination.system]?.transform; + const ratio = anchor[destination.system]?.ratio; + if (typeof transform === 'function') { + result = transform(result); + } else if (typeof ratio === 'number') { + result *= ratio; + } else { + throw Error('A system anchor needs to either have a defined ratio number or a transform function.'); + } + } + + if (destination.unit.anchor_shift) { + result += destination.unit.anchor_shift; + } + return result / destination.unit.to_anchor; + } + + // toBest(options?: { + // exclude?: (TUnits | (string & {}))[]; + // cutOffNumber?: number; + // system?: TSystems | (string & {}); + // }): BestResult | null { + // if (this.origin == null) + // throw new OperationOrderError('.toBest must be called after .from'); + // + // const isNegative = this.val < 0; + // + // let exclude: (TUnits | (string & {}))[] = []; + // let cutOffNumber = isNegative ? -1 : 1; + // let system: TSystems | (string & {}) = this.origin.system; + // + // if (typeof options === 'object') { + // exclude = options.exclude ?? []; + // cutOffNumber = options.cutOffNumber ?? cutOffNumber; + // system = options.system ?? this.origin.system; + // } + // + // let best: BestResult | null = null; + // /** + // Looks through every possibility for the 'best' available unit. + // i.e. Where the value has the fewest numbers before the decimal point, + // but is still higher than 1. + // */ + // for (const possibility of this.possibilities()) { + // const unit = this.describe(possibility); + // const isIncluded = exclude.indexOf(possibility) === -1; + // + // if (isIncluded && unit.system === system) { + // const result = this.to(possibility); + // if (isNegative ? result > cutOffNumber : result < cutOffNumber) { + // continue; + // } + // if ( + // best === null || + // (isNegative + // ? result <= cutOffNumber && result > best.val + // : result >= cutOffNumber && result < best.val) + // ) { + // best = { + // val: result, + // unit: possibility, + // name: unit.name, + // tags: unit.tags + // }; + // } + // } + // } + // + // if (best == null) { + // return { + // val: this.val, + // unit: this.origin.abbr, + // name: this.origin.unit.name, + // tags: this.origin.unit.tags + // }; + // } + // + // return best; + // } + + getUnit(abbr: TUnits | (string & {})): Conversion | null { + return this.unitCache.get(abbr) ?? null; + } + + describe(abbr: TUnits | (string & {})): UnitDescription { + const result = this.getUnit(abbr); + + if (result != null) { + return this.describeUnit(result); + } + return null; + } + + private describeUnit(unit: Conversion): UnitDescription { + return { + abbr: unit.abbr, + measure: unit.measure, + system: unit.system, + name: unit.unit.name, + tags: unit.unit.tags + }; + } + + list(measureName?: TMeasures | (string & {}), unitSystem?: UnitSystem): UnitDescription[] | never { + const list = []; + + if (isDefinedAndNotNull(measureName)) { + if (!this.isMeasure(measureName)) { + console.log(`Measure "${measureName}" not found.`); + return list; + } + const measure = this.measureData[measureName]; + if (isDefinedAndNotNull(unitSystem)) { + let currentUnitSystem = unitSystem; + let units = measure.systems[currentUnitSystem]; + if (isUndefinedOrNull(units)) { + if (currentUnitSystem === UnitSystem.IMPERIAL) { + currentUnitSystem = UnitSystem.METRIC; + units = measure.systems[currentUnitSystem]; + } + if (!units) { + console.log(`Measure "${measureName}" in ${currentUnitSystem} system is not found.`); + return list; + } + } + for (const [abbr, unit] of Object.entries( + units + )) { + list.push( + this.describeUnit({ + abbr: abbr as TUnits, + measure: measureName as TMeasures, + system: currentUnitSystem as TSystems, + unit: unit as Unit, + }) + ); + } + } else { + for (const [systemName, units] of Object.entries( + (measure as TbMeasure).systems + )) { + for (const [abbr, unit] of Object.entries( + units as Partial> + )) { + list.push( + this.describeUnit({ + abbr: abbr as TUnits, + measure: measureName as TMeasures, + system: systemName as TSystems, + unit: unit as Unit, + }) + ); + } + } + } + } else { + for (const [name, measure] of Object.entries(this.measureData)) { + if (isDefinedAndNotNull(unitSystem)) { + let currentUnitSystem = unitSystem; + let units = (measure as TbMeasure).systems[currentUnitSystem]; + if (isUndefinedOrNull(units)) { + if (currentUnitSystem === UnitSystem.IMPERIAL) { + currentUnitSystem = UnitSystem.METRIC; + units = (measure as TbMeasure).systems[currentUnitSystem]; + } + if (!units) { + console.log(`Measure "${measureName}" in ${currentUnitSystem} system is not found.`); + continue; + } + } + for (const [abbr, unit] of Object.entries( + units as Partial> + )) { + list.push( + this.describeUnit({ + abbr: abbr as TUnits, + measure: name as TMeasures, + system: currentUnitSystem as TSystems, + unit: unit as Unit, + }) + ); + } + } else { + for (const [systemName, units] of Object.entries( + (measure as TbMeasure).systems + )) { + for (const [abbr, unit] of Object.entries( + units as Partial> + )) { + list.push( + this.describeUnit({ + abbr: abbr as TUnits, + measure: name as TMeasures, + system: systemName as TSystems, + unit: unit as Unit, + }) + ); + } + } + } + } + } + + return list; + } + + private isMeasure(measureName: string): measureName is TMeasures { + return measureName in this.measureData; + } + + // possibilities(forMeasure?: TMeasures | (string & {})): TUnits[] { + // let possibilities: TUnits[] = []; + // let list_measures: TMeasures[] = []; + // + // if (typeof forMeasure == 'string' && this.isMeasure(forMeasure)) { + // list_measures.push(forMeasure); + // } else if (this.origin != null) { + // list_measures.push(this.origin.measure); + // } else { + // list_measures = Object.keys(this.measureData) as TMeasures[]; + // } + // + // for (const measure of list_measures) { + // const systems = this.measureData[measure].systems; + // + // for (const system of Object.values(systems)) { + // possibilities = [ + // ...possibilities, + // ...(Object.keys(system as Record) as TUnits[]), + // ]; + // } + // } + // + // return possibilities; + // } + + // measures(): TMeasures[] { + // return Object.keys(this.measureData) as TMeasures[]; + // } +} + +export function buildUnitCache< + TMeasures extends string, + TSystems extends UnitSystem, + TUnits extends string, +>(measures: Record>, + translate: TranslateService +) { + const unitCache: UnitCache = new Map(); + for (const [measureName, measure] of Object.entries(measures) as Entries< + typeof measures, + TMeasures + >[]) { + for (const [systemName, system] of Object.entries( + measure.systems + ) as Entries>, TSystems>[]) { + for (const [testAbbr, unit] of Object.entries(system) as Entries< + typeof system, + TUnits + >[]) { + unit.name = translate.instant(unit.name); + unitCache.set(testAbbr, { + measure: measureName, + system: systemName, + abbr: testAbbr, + unit, + }); + } + } + } + return unitCache; +} + +export function configureMeasurements< + TMeasures extends AllMeasures, + TSystems extends UnitSystem, + TUnits extends string, +>( + measures: Record>, + translate: TranslateService +): Converter { + if (typeof measures !== 'object') { + throw new TypeError('The measures argument needs to be an object'); + } + + const unitCache = buildUnitCache(measures, translate); + return new Converter(measures, unitCache); +} diff --git a/ui-ngx/src/app/core/services/unit/definitions/all.ts b/ui-ngx/src/app/core/services/unit/definitions/all.ts new file mode 100644 index 0000000000..7ae88112d3 --- /dev/null +++ b/ui-ngx/src/app/core/services/unit/definitions/all.ts @@ -0,0 +1,39 @@ +/// +/// Copyright © 2016-2025 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 temperature, { + TemperatureUnits, +} from './temperature'; +import time, { TimeUnits } from './time'; +import { TbMeasure, UnitSystem } from '@shared/models/unit.models'; + +export type AllMeasuresUnits = + | TemperatureUnits + | TimeUnits; + +export type AllMeasures = + | 'temperature' + | 'time'; + +const allMeasures: Record< + AllMeasures, + TbMeasure +> = { + temperature, + time, +}; + +export default allMeasures; diff --git a/ui-ngx/src/app/core/services/unit/definitions/temperature.ts b/ui-ngx/src/app/core/services/unit/definitions/temperature.ts new file mode 100644 index 0000000000..23b5831dcb --- /dev/null +++ b/ui-ngx/src/app/core/services/unit/definitions/temperature.ts @@ -0,0 +1,77 @@ +/// +/// Copyright © 2016-2025 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 { TbMeasure, Unit, UnitSystem } from '@shared/models/unit.models'; + +export type TemperatureMetricUnits = '°C' | 'K'; +export type TemperatureImperialUnits = '°F' | '°R'; + +export type TemperatureUnits = + | TemperatureMetricUnits + | TemperatureImperialUnits; + +const METRIC: Record = { + '°C': { + name: 'unit.celsius', + tags: ['temperature','heat','cold','warmth','degrees','celsius','shipment condition','°C'], + to_anchor: 1, + }, + K: { + name: 'unit.kelvin', + tags: ['temperature','heat','cold','warmth','degrees','kelvin','K','color quality','white balance','color temperature'], + to_anchor: 1, + anchor_shift: 273.15, + }, +}; + +const IMPERIAL: Record = { + '°F': { + name: 'unit.fahrenheit', + tags: ['temperature','heat','cold','warmth','degrees','fahrenheit','°F'], + to_anchor: 1, + }, + '°R': { + name: 'unit.rankine', + tags: ['temperature','heat','cold','warmth','Rankine','°R'], + to_anchor: 1, + anchor_shift: 459.67, + }, +}; + +const measure: TbMeasure = { + systems: { + METRIC, + IMPERIAL, + }, + anchors: { + METRIC: { + IMPERIAL: { + transform: function (C: number): number { + return C / (5 / 9) + 32; + }, + }, + }, + IMPERIAL: { + METRIC: { + transform: function (F: number): number { + return (F - 32) * (5 / 9); + }, + }, + }, + }, +}; + +export default measure; diff --git a/ui-ngx/src/app/core/services/unit/definitions/time.ts b/ui-ngx/src/app/core/services/unit/definitions/time.ts new file mode 100644 index 0000000000..aead2db33c --- /dev/null +++ b/ui-ngx/src/app/core/services/unit/definitions/time.ts @@ -0,0 +1,76 @@ +/// +/// Copyright © 2016-2025 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 { TbMeasure, Unit, UnitSystem } from '@shared/models/unit.models'; + +export type TimeUnits = TimeSIUnits; + +export type TimeSIUnits = + | 's' + | 'min' + | 'h' + | 'd' + | 'wk' + | 'mo' + | 'yr'; + +const daysInYear = 365.25; + +const METRIC: Record = { + s: { + name: 'unit.second', + tags: ["time","duration","interval","angle","second","arcsecond","sec"], + to_anchor: 1, + }, + min: { + name: 'unit.minute', + tags: ["time","duration","interval","angle","minute","arcminute","min"], + to_anchor: 60, + }, + h: { + name: 'unit.hour', + tags: ["time","duration","interval","h"], + to_anchor: 60 * 60, + }, + d: { + name: 'unit.day', + tags: ["time","duration","interval","d"], + to_anchor: 60 * 60 * 24, + }, + wk: { + name: 'unit.week', + tags: ["time","duration","interval","wk"], + to_anchor: 60 * 60 * 24 * 7, + }, + mo: { + name: 'unit.month', + tags: ["time","duration","interval","mo"], + to_anchor: (60 * 60 * 24 * daysInYear) / 12, + }, + yr: { + name: 'unit.year', + tags: ["time","duration","interval","yr"], + to_anchor: 60 * 60 * 24 * daysInYear, + }, +}; + +const measure: TbMeasure = { + systems: { + METRIC, + }, +}; + +export default measure; diff --git a/ui-ngx/src/app/core/services/unit/unit.service.ts b/ui-ngx/src/app/core/services/unit/unit.service.ts new file mode 100644 index 0000000000..4013e71ced --- /dev/null +++ b/ui-ngx/src/app/core/services/unit/unit.service.ts @@ -0,0 +1,89 @@ +/// +/// Copyright © 2016-2025 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 { Injectable } from '@angular/core'; +import moment from 'moment-timezone'; +import { TbUnitConvertor, UnitDescription, UnitSystem } from '@shared/models/unit.models'; +import { isNotEmptyStr } from '@core/utils'; +import { configureMeasurements, Converter } from '@core/services/unit/converter-unit'; +import allMeasures, { AllMeasures, AllMeasuresUnits } from '@core/services/unit/definitions/all'; +import { TranslateService } from '@ngx-translate/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@Injectable({ + providedIn: 'root' +}) +export class UnitService { + + private currentUnitSystem: UnitSystem = UnitSystem.METRIC; + private converter: Converter; + + constructor(private store: Store, + private translate: TranslateService) { + this.translate.onLangChange.pipe( + takeUntilDestroyed() + ).subscribe(() => { + this.converter = configureMeasurements(allMeasures, this.translate); + console.warn(this.converter?.list()); + console.warn(this.converter?.list('temperature')); + console.warn(this.converter?.list('temperature', UnitSystem.METRIC)); + console.warn(this.converter?.list(null, UnitSystem.IMPERIAL)); + }); + } + + getUnitSystem(): UnitSystem { + return this.currentUnitSystem; + } + + setUnitSystem(unitSystem: UnitSystem) { + if (isNotEmptyStr(unitSystem)) { + this.currentUnitSystem = unitSystem; + } else { + this.currentUnitSystem = this.getUnitSystemByTimezone(); + } + console.warn('[Unit system] setUnitSystem', this.currentUnitSystem); + } + + getUnits(measure?: AllMeasures, unitSystem?: UnitSystem): UnitDescription[] { + return this.converter?.list(measure, unitSystem) ?? []; + } + + getUnitDescription(abbr: AllMeasuresUnits | string): UnitDescription { + return this.converter.describe(abbr); + } + + geUnitConvertor(from: string, to: string): TbUnitConvertor { + return this.converter.convertor(from, to); + } + + convertValue(value: number, from: string, to: string): number { + return this.converter.convert(value, from, to); + } + + private getUnitSystemByTimezone(): UnitSystem { + const timeZone = moment.tz.guess(true); + const imperialCountries = ['US', 'LR', 'MM']; + + if (moment.tz.zonesForCountry('GB').includes(timeZone)) { + return UnitSystem.HYBRID; + } + return imperialCountries.some(country => + moment.tz.zonesForCountry(country).includes(timeZone) + ) ? UnitSystem.IMPERIAL : UnitSystem.METRIC; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html index b8a67c744d..644a09fa5b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html @@ -84,7 +84,7 @@
widgets.value-card.value
- +
widget-config.decimals-suffix
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts index 68dc8fb232..7404f95cdf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts @@ -27,7 +27,7 @@ import { ViewChild } from '@angular/core'; import { WidgetContext } from '@home/models/widget-component.models'; -import { formatValue, isDefinedAndNotNull } from '@core/utils'; +import { isDefinedAndNotNull } from '@core/utils'; import { backgroundStyle, ColorProcessor, @@ -46,6 +46,7 @@ import { WidgetComponent } from '@home/components/widget/widget.component'; import { Observable } from 'rxjs'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; +import { FormatValueProcessor } from '@shared/models/unit.models'; const squareLayoutSize = 160; const horizontalLayoutHeight = 80; @@ -100,8 +101,7 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro private panelResize$: ResizeObserver; private horizontal = false; - private decimals = 0; - private units = ''; + private formatValue: FormatValueProcessor; constructor(private imagePipe: ImagePipe, private sanitizer: DomSanitizer, @@ -116,15 +116,16 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro this.ctx.$scope.valueCardWidget = this; this.settings = {...valueCardDefaultSettings(this.horizontal), ...this.ctx.settings}; - this.decimals = this.ctx.decimals; - this.units = this.ctx.units; + let decimals = this.ctx.decimals; + let units = this.ctx.units; const dataKey = getDataKey(this.ctx.datasources); if (isDefinedAndNotNull(dataKey?.decimals)) { - this.decimals = dataKey.decimals; + decimals = dataKey.decimals; } if (dataKey?.units) { - this.units = dataKey.units; + units = dataKey.units; } + this.formatValue = FormatValueProcessor.fromSettings(this.ctx.$injector, {units: units, dec: decimals}); this.layout = this.settings.layout; @@ -187,7 +188,7 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro if (tsValue && isDefinedAndNotNull(tsValue[1]) && tsValue[0] !== 0) { ts = tsValue[0]; value = tsValue[1]; - this.valueText = formatValue(value, this.decimals, this.units, false); + this.valueText = this.formatValue.format(value); // formatValue(value, this.decimals, this.units, false); } else { this.valueText = 'N/A'; } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index 839af59a5c..44eea5865e 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -66,6 +66,16 @@
+ + unit.unit-system + + {{ 'unit.unit-system-type.AUTO' | translate }} + @for(unit of UnitSystems; track unit) { + {{ 'unit.unit-system-type.' + unit | translate }} + } + +
, @@ -50,7 +53,8 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir private userService: UserService, private authService: AuthService, private translate: TranslateService, - public fb: UntypedFormBuilder) { + private unitService: UnitService, + private fb: UntypedFormBuilder) { super(store); this.authUser = getCurrentAuthUser(this.store); } @@ -67,6 +71,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir lastName: [''], phone: [''], language: [''], + unitSystem: [''], homeDashboardId: [null], homeDashboardHideToolbar: [true] }); @@ -80,6 +85,11 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir this.user.additionalInfo.lang = this.profile.get('language').value; this.user.additionalInfo.homeDashboardId = this.profile.get('homeDashboardId').value; this.user.additionalInfo.homeDashboardHideToolbar = this.profile.get('homeDashboardHideToolbar').value; + if (isNotEmptyStr(this.profile.get('unitSystem').value)) { + this.user.additionalInfo.unitSystem = this.profile.get('unitSystem').value; + } else { + delete this.user.additionalInfo.unitSystem; + } this.userService.saveUser(this.user).subscribe( (user) => { this.userLoaded(user); @@ -96,6 +106,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir lastName: user.lastName, } })); this.store.dispatch(new ActionSettingsChangeLanguage({ userLang: user.additionalInfo.lang })); + this.unitService.setUnitSystem(this.user.additionalInfo.unitSystem); this.authService.refreshJwtToken(false); } ); @@ -107,6 +118,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir let lang; let homeDashboardId; let homeDashboardHideToolbar = true; + let unitSystem: UnitSystem = null; if (user.additionalInfo) { if (user.additionalInfo.lang) { lang = user.additionalInfo.lang; @@ -115,11 +127,15 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir if (isDefinedAndNotNull(user.additionalInfo.homeDashboardHideToolbar)) { homeDashboardHideToolbar = user.additionalInfo.homeDashboardHideToolbar; } + if (isNotEmptyStr(user.additionalInfo.unitSystem)) { + unitSystem = user.additionalInfo.unitSystem; + } } if (!lang) { lang = this.translate.currentLang; } this.profile.get('language').setValue(lang); + this.profile.get('unitSystem').setValue(unitSystem); this.profile.get('homeDashboardId').setValue(homeDashboardId); this.profile.get('homeDashboardHideToolbar').setValue(homeDashboardHideToolbar); } diff --git a/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.html b/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.html new file mode 100644 index 0000000000..8ca777ffcf --- /dev/null +++ b/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.html @@ -0,0 +1,71 @@ + +
+
Unit convertion settings
+
+
+
From
+ +
+
+ + Convert units + +
+ @if(convertUnitForm.get('convertUnit').value) { +
+
Metrical
+ + +
+
+
Imperial
+ + +
+
+
Hybrid
+ + +
+ } +
+
+ + +
+
diff --git a/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.scss b/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.scss new file mode 100644 index 0000000000..1613e4135e --- /dev/null +++ b/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 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-convert-settings-panel { + width: 320px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-convert-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-convert-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-convert-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.ts b/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.ts new file mode 100644 index 0000000000..370cc30985 --- /dev/null +++ b/ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.ts @@ -0,0 +1,138 @@ +/// +/// Copyright © 2016-2025 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, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { TbUnit, UnitDescription, UnitSystem } from '@shared/models/unit.models'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { FormBuilder, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { UnitService } from '@core/services/unit/unit.service'; +import { AllMeasures } from '@core/services/unit/definitions/all'; +import { debounceTime } from 'rxjs/operators'; + +@Component({ + selector: 'tb-covert-unit-settings-panel', + templateUrl: './convert-unit-settings-panel.component.html', + styleUrls: ['./convert-unit-settings-panel.component.scss'], + providers: [], + encapsulation: ViewEncapsulation.None +}) +export class ConvertUnitSettingsPanelComponent implements OnInit { + + @Input() + unit: TbUnit; + + @Input() + required: boolean; + + @Output() + unitSettingsApplied = new EventEmitter(); + + UnitSystem = UnitSystem; + + measure: AllMeasures; + + convertUnitForm = this.fb.group({ + from: [''], + convertUnit: [true], + METRIC: [''], + IMPERIAL: [''], + HYBRID: [''] + }) + + constructor( + private popover: TbPopoverComponent, + private fb: FormBuilder, + private unitService: UnitService + ) { + this.convertUnitForm.get('from').valueChanges.pipe( + debounceTime(200), + takeUntilDestroyed() + ).subscribe(unit => { + const unitDescription = this.unitService.getUnitDescription(unit); + if (unitDescription) { + this.convertUnitForm.get('convertUnit').enable({emitEvent: true}); + this.measure = unitDescription.measure; + if (unitDescription.system === UnitSystem.IMPERIAL) { + this.convertUnitForm.get('IMPERIAL').setValue(unit, {emitEvent: false}); + this.convertUnitForm.get('HYBRID').setValue(unit, {emitEvent: false}); + } else { + this.convertUnitForm.get('METRIC').setValue(unit, {emitEvent: false}); + this.convertUnitForm.get('HYBRID').setValue(unit, {emitEvent: false}); + } + } else { + this.convertUnitForm.get('convertUnit').setValue(false, {onlySelf: true}); + this.convertUnitForm.get('convertUnit').disable({emitEvent: false}); + } + }) + + this.convertUnitForm.get('convertUnit').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(value => { + if (value) { + this.convertUnitForm.get('METRIC').enable({emitEvent: false}); + this.convertUnitForm.get('IMPERIAL').enable({emitEvent: false}); + this.convertUnitForm.get('HYBRID').enable({emitEvent: false}); + } else { + this.convertUnitForm.get('METRIC').disable({emitEvent: false}); + this.convertUnitForm.get('IMPERIAL').disable({emitEvent: false}); + this.convertUnitForm.get('HYBRID').disable({emitEvent: false}); + } + setTimeout(() => { + this.popover.updatePosition(); + }, 0); + }); + } + + ngOnInit() { + let unitDescription: UnitDescription; + if (this.required) { + this.convertUnitForm.get('from').setValidators(Validators.required); + this.convertUnitForm.get('from').updateValueAndValidity({emitEvent: false}); + } + if (typeof this.unit === 'string') { + this.convertUnitForm.get('convertUnit').setValue(false, {onlySelf: true}); + this.convertUnitForm.get('from').setValue(this.unit, {emitEvent: true}); + unitDescription = this.unitService.getUnitDescription(this.unit); + } else if (this.unit === null) { + this.convertUnitForm.get('convertUnit').setValue(false, {onlySelf: true}); + this.convertUnitForm.get('from').setValue(null, {emitEvent: true}); + } else { + this.convertUnitForm.patchValue(this.unit, {emitEvent: false}); + unitDescription = this.unitService.getUnitDescription(this.unit.from); + } + + if (unitDescription?.measure) { + this.measure = unitDescription.measure; + } else { + this.convertUnitForm.get('convertUnit').disable({emitEvent: false}); + } + } + + cancel() { + this.popover.hide(); + } + + applyUnitSettings() { + if (this.convertUnitForm.value.convertUnit) { + const formValue = this.convertUnitForm.value; + delete formValue.convertUnit; + this.unitSettingsApplied.emit(formValue as TbUnit); + } else { + this.unitSettingsApplied.emit(this.convertUnitForm.value.from); + } + } +} diff --git a/ui-ngx/src/app/shared/components/unit-input.component.html b/ui-ngx/src/app/shared/components/unit-input.component.html index 00286fd422..ab5932dcb9 100644 --- a/ui-ngx/src/app/shared/components/unit-input.component.html +++ b/ui-ngx/src/app/shared/components/unit-input.component.html @@ -15,11 +15,18 @@ limitations under the License. --> - + +