Browse Source

UI: Add init unit convertor and service

pull/13282/head
Vladyslav_Prykhodko 1 year ago
parent
commit
6d2d07da24
  1. 8
      ui-ngx/src/app/app.component.ts
  2. 444
      ui-ngx/src/app/core/services/unit/converter-unit.ts
  3. 39
      ui-ngx/src/app/core/services/unit/definitions/all.ts
  4. 77
      ui-ngx/src/app/core/services/unit/definitions/temperature.ts
  5. 76
      ui-ngx/src/app/core/services/unit/definitions/time.ts
  6. 89
      ui-ngx/src/app/core/services/unit/unit.service.ts
  7. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html
  8. 17
      ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts
  9. 10
      ui-ngx/src/app/modules/home/pages/profile/profile.component.html
  10. 20
      ui-ngx/src/app/modules/home/pages/profile/profile.component.ts
  11. 71
      ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.html
  12. 49
      ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.scss
  13. 138
      ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.ts
  14. 21
      ui-ngx/src/app/shared/components/unit-input.component.html
  15. 198
      ui-ngx/src/app/shared/components/unit-input.component.ts
  16. 159
      ui-ngx/src/app/shared/models/unit.models.ts
  17. 2
      ui-ngx/src/app/shared/models/user.model.ts
  18. 2
      ui-ngx/src/app/shared/shared.module.ts
  19. 7
      ui-ngx/src/assets/locale/locale.constant-en_US.json

8
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) => {

444
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<TUnits extends string> {
// val: number;
// unit: TUnits;
// name: string;
// tags: string[];
// }
type Entries<T, S extends keyof T> = [S, T[keyof T]];
export type UnitCache<TMeasures, TSystems, TUnits> = 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<TMeasures, TbMeasure<TSystems, TUnits>>;
private unitCache: Map<
string,
{
system: TSystems;
measure: TMeasures;
unit: Unit;
abbr: TUnits;
}
>;
constructor(
measures: Record<TMeasures, TbMeasure<TSystems, TUnits>>,
unitCache: UnitCache<TMeasures, TSystems, TUnits>
) {
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<TUnits> | 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<TUnits> | 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<TMeasures, TSystems, TUnits> | 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<TMeasures, TSystems, TUnits>): 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<TSystems, TUnits>).systems
)) {
for (const [abbr, unit] of Object.entries(
units as Partial<Record<TUnits, Unit>>
)) {
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<TSystems, TUnits>).systems[currentUnitSystem];
if (isUndefinedOrNull(units)) {
if (currentUnitSystem === UnitSystem.IMPERIAL) {
currentUnitSystem = UnitSystem.METRIC;
units = (measure as TbMeasure<TSystems, TUnits>).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<Record<TUnits, Unit>>
)) {
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<TSystems, TUnits>).systems
)) {
for (const [abbr, unit] of Object.entries(
units as Partial<Record<TUnits, Unit>>
)) {
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<TUnits, Unit>) 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<TMeasures, TbMeasure<TSystems, TUnits>>,
translate: TranslateService
) {
const unitCache: UnitCache<TMeasures, TSystems, TUnits> = 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<Record<TSystems, Record<TUnits, Unit>>, 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<TMeasures, TbMeasure<TSystems, TUnits>>,
translate: TranslateService
): Converter<TMeasures, TSystems, TUnits> {
if (typeof measures !== 'object') {
throw new TypeError('The measures argument needs to be an object');
}
const unitCache = buildUnitCache(measures, translate);
return new Converter<TMeasures, TSystems, TUnits>(measures, unitCache);
}

39
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<UnitSystem, AllMeasuresUnits>
> = {
temperature,
time,
};
export default allMeasures;

77
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<TemperatureMetricUnits, Unit> = {
'°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<TemperatureImperialUnits, Unit> = {
'°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<UnitSystem, TemperatureUnits> = {
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;

76
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<TimeSIUnits, Unit> = {
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<UnitSystem, TimeUnits> = {
systems: {
METRIC,
},
};
export default measure;

89
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<AllMeasures, UnitSystem, AllMeasuresUnits>;
constructor(private store: Store<AppState>,
private translate: TranslateService) {
this.translate.onLangChange.pipe(
takeUntilDestroyed()
).subscribe(() => {
this.converter = configureMeasurements<AllMeasures, UnitSystem, AllMeasuresUnits>(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;
}
}

2
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html

@ -84,7 +84,7 @@
<div class="tb-form-row">
<div class="fixed-title-width" translate>widgets.value-card.value</div>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<tb-unit-input class="flex" formControlName="units"></tb-unit-input>
<tb-unit-input class="flex" formControlName="units" allowConverted></tb-unit-input>
<mat-form-field appearance="outline" class="number flex" subscriptSizing="dynamic">
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix class="lt-md:!hidden" translate>widget-config.decimals-suffix</div>

17
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';
}

10
ui-ngx/src/app/modules/home/pages/profile/profile.component.html

@ -66,6 +66,16 @@
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block" floatLabel="always">
<mat-label translate>unit.unit-system</mat-label>
<mat-select formControlName="unitSystem"
[placeholder]="'unit.unit-system-type.AUTO' | translate">
<mat-option [value]="null">{{ 'unit.unit-system-type.AUTO' | translate }}</mat-option>
@for(unit of UnitSystems; track unit) {
<mat-option [value]="unit">{{ 'unit.unit-system-type.' + unit | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<section class="tb-home-dashboard flex flex-1 flex-col gt-sm:flex-row" *ngIf="!isSysAdmin()">
<tb-dashboard-autocomplete
class="flex-1"

20
ui-ngx/src/app/modules/home/pages/profile/profile.component.ts

@ -28,9 +28,11 @@ import { environment as env } from '@env/environment';
import { TranslateService } from '@ngx-translate/core';
import { ActionSettingsChangeLanguage } from '@core/settings/settings.actions';
import { ActivatedRoute } from '@angular/router';
import { isDefinedAndNotNull } from '@core/utils';
import { isDefinedAndNotNull, isNotEmptyStr } from '@core/utils';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { AuthService } from '@core/auth/auth.service';
import { UnitSystem, UnitSystems } from '@shared/models/unit.models';
import { UnitService } from '@core/services/unit/unit.service';
@Component({
selector: 'tb-profile',
@ -43,6 +45,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir
profile: UntypedFormGroup;
user: User;
languageList = env.supportedLangs;
UnitSystems = UnitSystems;
private readonly authUser: AuthUser;
constructor(protected store: Store<AppState>,
@ -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);
}

71
ui-ngx/src/app/shared/components/convert-unit-settings-panel.component.html

@ -0,0 +1,71 @@
<!--
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.
-->
<div class="tb-convert-settings-panel">
<div class="tb-convert-settings-title">Unit convertion settings</div>
<div class="tb-convert-settings-panel-content" [formGroup]="convertUnitForm">
<div class="tb-form-row">
<div class="min-w-25">From</div>
<tb-unit-input class="flex-1" [required]="required" formControlName="from"></tb-unit-input>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="convertUnit">
Convert units
</mat-slide-toggle>
</div>
@if(convertUnitForm.get('convertUnit').value) {
<div class="tb-form-row">
<div class="min-w-25">Metrical</div>
<tb-unit-input class="flex-1"
formControlName="METRIC"
[unitSystem]="UnitSystem.METRIC"
[measure]="measure">
</tb-unit-input>
</div>
<div class="tb-form-row">
<div class="min-w-25">Imperial</div>
<tb-unit-input class="flex-1"
formControlName="IMPERIAL"
[unitSystem]="UnitSystem.IMPERIAL"
[measure]="measure">
</tb-unit-input>
</div>
<div class="tb-form-row">
<div class="min-w-25">Hybrid</div>
<tb-unit-input class="flex-1"
formControlName="HYBRID"
[measure]="measure">
</tb-unit-input>
</div>
}
</div>
<div class="tb-convert-settings-panel-buttons">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyUnitSettings()"
[disabled]="convertUnitForm.invalid || convertUnitForm.pristine">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

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

138
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<TbUnit>();
UnitSystem = UnitSystem;
measure: AllMeasures;
convertUnitForm = this.fb.group({
from: [''],
convertUnit: [true],
METRIC: [''],
IMPERIAL: [''],
HYBRID: ['']
})
constructor(
private popover: TbPopoverComponent<ConvertUnitSettingsPanelComponent>,
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);
}
}
}

21
ui-ngx/src/app/shared/components/unit-input.component.html

@ -15,11 +15,18 @@
limitations under the License.
-->
<mat-form-field appearance="outline" class="tb-inline-field tb-suffix-show-on-hover flex-1" subscriptSizing="dynamic" style="width: 100%;">
<mat-form-field appearance="outline" class="tb-inline-field tb-suffix-show-on-hover w-full flex-1" subscriptSizing="dynamic">
<input matInput #unitInput [formControl]="unitsFormControl"
placeholder="{{ 'widget-config.set' | translate }}"
(focusin)="onFocus()"
[matAutocomplete]="unitsAutocomplete">
<button type="button"
*ngIf="!disabled && allowConverted"
class="tb-icon-24"
[class.mr-2]="!unitsFormControl.value || disabled || unitsFormControl.invalid"
matSuffix mat-icon-button (click)="openConvertSettingsPopup($event)">
<tb-icon>mdi:tape-measure</tb-icon>
</button>
<button *ngIf="unitsFormControl.value && !disabled && unitsFormControl.valid"
type="button"
class="tb-icon-24 mr-2"
@ -38,10 +45,12 @@
#unitsAutocomplete="matAutocomplete"
class="tb-autocomplete tb-unit-input-autocomplete"
panelWidth="fit-content"
[displayWith]="displayUnitFn">
<mat-option *ngFor="let unit of filteredUnits | async" [value]="unit">
<span class="tb-unit-name flex-1" [innerHTML]="unit.name | highlight:searchText:true:'ig'"></span>
<span class="tb-unit-symbol" [innerHTML]="unit.symbol | highlight:searchText:true:'ig'"></span>
</mat-option>
[displayWith]="displayUnitFn.bind(this)">
@for(unit of filteredUnits | async; track unit.abbr) {
<mat-option [value]="unit">
<span class="tb-unit-name flex-1" [innerHTML]="unit.name | highlight:searchText:true:'ig'"></span>
<span class="tb-unit-symbol" [innerHTML]="unit.abbr | highlight:searchText:true:'ig'"></span>
</mat-option>
}
</mat-autocomplete>
</mat-form-field>

198
ui-ngx/src/app/shared/components/unit-input.component.ts

@ -15,28 +15,28 @@
///
import {
booleanAttribute,
Component,
ElementRef,
forwardRef,
HostBinding,
Input,
OnChanges,
OnInit,
Renderer2,
SimpleChanges,
ViewChild,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
FormControl,
NG_VALUE_ACCESSOR,
Validators
} from '@angular/forms';
import { Observable, of, shareReplay, switchMap } from 'rxjs';
import { getUnits, searchUnits, Unit, unitBySymbol, UnitsType } from '@shared/models/unit.models';
import { map, mergeMap, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { ResourcesService } from '@core/services/resources.service';
import { coerceBoolean } from '@shared/decorators/coercion';
import { ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { Observable, of, shareReplay } from 'rxjs';
import { searchUnits, TbUnit, UnitDescription, UnitsType, UnitSystem } from '@shared/models/unit.models';
import { map, mergeMap } from 'rxjs/operators';
import { AllMeasures } from '@core/services/unit/definitions/all';
import { UnitService } from '@core/services/unit/unit.service';
import { TbPopoverService } from '@shared/components/popover.service';
import { ConvertUnitSettingsPanelComponent } from '@shared/components/convert-unit-settings-panel.component';
@Component({
selector: 'tb-unit-input',
@ -51,64 +51,84 @@ import { coerceBoolean } from '@shared/decorators/coercion';
],
encapsulation: ViewEncapsulation.None
})
export class UnitInputComponent implements ControlValueAccessor, OnInit {
@HostBinding('style.display') get hostDisplay() {return 'flex';};
export class UnitInputComponent implements ControlValueAccessor, OnInit, OnChanges {
unitsFormControl: FormControl;
@HostBinding('style.display') readonly hostDisplay = 'flex';
@ViewChild('unitInput', {static: true}) unitInput: ElementRef;
modelValue: string | null;
unitsFormControl: FormControl<TbUnit | UnitDescription>;
@Input()
disabled: boolean;
@Input()
@coerceBoolean()
@Input({transform: booleanAttribute})
required = false;
@Input()
tagFilter: UnitsType;
@ViewChild('unitInput', {static: true}) unitInput: ElementRef;
@Input()
measure: AllMeasures;
@Input()
unitSystem: UnitSystem;
@Input({transform: booleanAttribute})
allowConverted = false;
filteredUnits: Observable<Array<Unit | string>>;
filteredUnits: Observable<Array<UnitDescription>>;
searchText = '';
private dirty = false;
private fetchUnits$: Observable<Array<Unit>> = null;
private modelValue: TbUnit | null;
private fetchUnits$: Observable<Array<UnitDescription>> = null;
private propagateChange = (_val: any) => {};
constructor(private fb: FormBuilder,
private resourcesService: ResourcesService,
private translate: TranslateService) {
private unitService: UnitService,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private elementRef: ElementRef) {
}
ngOnInit() {
this.unitsFormControl = this.fb.control('', this.required ? [Validators.required] : []);
this.unitsFormControl = this.fb.control<TbUnit | UnitDescription>('', this.required ? [Validators.required] : []);
this.filteredUnits = this.unitsFormControl.valueChanges
.pipe(
tap(value => {
map(value => {
this.updateView(value);
return this.getUnitSymbol(value);
}),
map(value => (value as Unit)?.symbol ? (value as Unit).symbol : (value ? value as string : '')),
mergeMap(symbol => this.fetchUnits(symbol))
);
}
writeValue(symbol?: string): void {
ngOnChanges(changes: SimpleChanges) {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'measure' || propName === 'unitSystem') {
this.fetchUnits$ = null;
this.dirty = true;
}
}
}
}
writeValue(symbol?: TbUnit): void {
this.searchText = '';
this.modelValue = symbol;
of(symbol).pipe(
switchMap(value => value
? this.unitsConstant().pipe(map(units => unitBySymbol(units, value) ?? value))
: of(null))
).subscribe(result => {
this.unitsFormControl.patchValue(result, {emitEvent: false});
this.dirty = true;
});
if (typeof symbol === 'string') {
this.unitsFormControl.patchValue(this.unitService.getUnitDescription(symbol) ?? symbol, {emitEvent: false});
} else {
this.unitsFormControl.patchValue(symbol, {emitEvent: false});
}
this.dirty = true;
}
onFocus() {
@ -118,37 +138,18 @@ export class UnitInputComponent implements ControlValueAccessor, OnInit {
}
}
updateView(value: Unit | string | null) {
const res: string = (value as Unit)?.symbol ? (value as Unit)?.symbol : (value as string);
if (this.modelValue !== res) {
this.modelValue = res;
this.propagateChange(this.modelValue);
}
}
displayUnitFn(unit?: Unit | string): string | undefined {
displayUnitFn(unit?: TbUnit | UnitDescription): string | undefined {
if (unit) {
if ((unit as Unit).symbol) {
return (unit as Unit).symbol;
} else {
return unit as string;
}
return this.getUnitSymbol(unit);
}
return undefined;
}
fetchUnits(searchText?: string): Observable<Array<Unit | string>> {
this.searchText = searchText;
return this.unitsConstant().pipe(
map(unit => searchUnits(unit, searchText))
);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
@ -168,22 +169,87 @@ export class UnitInputComponent implements ControlValueAccessor, OnInit {
}, 0);
}
private unitsConstant(): Observable<Array<Unit>> {
openConvertSettingsPopup($event: Event) {
if ($event) {
$event.stopPropagation();
}
const trigger = this.elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const convertUnitSettingsPanelPopover = this.popoverService.displayPopover({
trigger,
renderer: this.renderer,
componentType: ConvertUnitSettingsPanelComponent,
hostView: this.viewContainerRef,
preferredPlacement: ['left', 'bottom', 'top'],
context: {
unit: this.getTbUnit(this.unitsFormControl.value),
required: this.required
},
isModal: true
});
convertUnitSettingsPanelPopover.tbComponentRef.instance.unitSettingsApplied.subscribe((unitSetting) => {
convertUnitSettingsPanelPopover.hide();
this.unitsFormControl.patchValue(unitSetting, {emitEvent: false});
this.updateView(unitSetting);
});
}
}
private updateView(value: UnitDescription | TbUnit ) {
const res = this.getTbUnit(value);
if (this.modelValue !== res) {
this.modelValue = res;
this.propagateChange(this.modelValue);
}
}
private fetchUnits(searchText?: string): Observable<Array<UnitDescription>> {
this.searchText = searchText;
return this.unitsConstant().pipe(
map(unit => searchUnits(unit, searchText))
);
}
private unitsConstant(): Observable<Array<UnitDescription>> {
if (this.fetchUnits$ === null) {
this.fetchUnits$ = getUnits(this.resourcesService).pipe(
this.fetchUnits$ = of(this.unitService.getUnits(this.measure, this.unitSystem)).pipe(
map((units) => {
if (this.tagFilter) {
units = units.filter(u => u.tags.includes(this.tagFilter));
}
return units.map(u => ({
symbol: u.symbol,
name: this.translate.instant(u.name),
tags: u.tags
}));
return units;
}),
shareReplay(1)
);
}
return this.fetchUnits$;
}
private getUnitSymbol(value: TbUnit | UnitDescription | null): string {
if (value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if ('abbr' in value) {
return value.abbr;
}
return value.from;
}
private getTbUnit(value: TbUnit | UnitDescription | null): TbUnit {
if (value === null) {
return null;
}
if (typeof value === 'string') {
return value;
}
if ('abbr' in value) {
return value.abbr;
}
return value;
}
}

159
ui-ngx/src/app/shared/models/unit.models.ts

@ -14,34 +14,165 @@
/// limitations under the License.
///
import { ResourcesService } from '@core/services/resources.service';
import { Observable } from 'rxjs';
import { AllMeasures } from '@core/services/unit/definitions/all';
import { Injector } from '@angular/core';
import { isDefinedAndNotNull, isNotEmptyStr, isNumeric } from '@core/utils';
import { UnitService } from '@core/services/unit/unit.service';
export enum UnitsType {
capacity = 'capacity'
}
export type TbUnitConvertor = (value: number) => number;
export interface UnitDescription {
abbr: string;
measure: AllMeasures;
system: UnitSystem;
name: string;
tags: string[];
}
export enum UnitSystem {
METRIC = 'METRIC',
IMPERIAL = 'IMPERIAL',
HYBRID = 'HYBRID'
}
export const UnitSystems = Object.values(UnitSystem);
export interface Unit {
name: string;
symbol: string;
tags: string[];
to_anchor: number;
anchor_shift?: number;
}
export enum UnitsType {
capacity = 'capacity'
export type TbUnit = string | TbUnitMapping;
export interface TbUnitMapping {
from: string;
METRIC: string;
IMPERIAL: string;
HYBRID: string;
}
export enum Units {
percent = '%',
liters = 'L'
export interface TbAnchor {
ratio?: number;
transform?: (value: number) => number;
}
export const unitBySymbol = (_units: Array<Unit>, symbol: string): Unit => _units.find(u => u.symbol === symbol);
export interface TbMeasure<TSystems extends UnitSystem, TUnits extends string> {
systems: Partial<Record<TSystems, Partial<Record<TUnits, Unit>>>>;
anchors?: Partial<Record<TSystems, Partial<Record<TSystems, TbAnchor>>>>;
}
const searchUnitTags = (unit: Unit, searchText: string): boolean =>
const searchUnitTags = (unit: UnitDescription, searchText: string): boolean =>
!!unit.tags.find(t => t.toUpperCase().includes(searchText.toUpperCase()));
export const searchUnits = (_units: Array<Unit>, searchText: string): Array<Unit> => _units.filter(
u => u.symbol.toUpperCase().includes(searchText.toUpperCase()) ||
export const searchUnits = (_units: Array<UnitDescription>, searchText: string): Array<UnitDescription> => _units.filter(
u => u.abbr.toUpperCase().includes(searchText.toUpperCase()) ||
u.name.toUpperCase().includes(searchText.toUpperCase()) ||
searchUnitTags(u, searchText)
);
export const getUnits = (resourcesService: ResourcesService): Observable<Array<Unit>> =>
resourcesService.loadJsonResource('/assets/metadata/units.json');
export interface FormatValueSettingProcessor {
dec?: number;
units?: TbUnit;
showZeroDecimals?: boolean;
}
export abstract class FormatValueProcessor {
static fromSettings($injector: Injector, settings: FormatValueSettingProcessor): FormatValueProcessor {
if (typeof settings.units !== 'string' && isDefinedAndNotNull(settings.units?.from)) {
return new ConvertUnitProcessor($injector, settings)
} else {
return new SimpleUnitProcessor($injector, settings);
}
}
protected constructor(protected $injector: Injector,
protected settings: FormatValueSettingProcessor) {
}
abstract format(value: any): string;
}
export class SimpleUnitProcessor extends FormatValueProcessor {
private readonly isDefinedUnit: boolean;
private readonly isDefinedDec: boolean;
private readonly showZeroDecimals: boolean;
constructor(protected $injector: Injector,
protected settings: FormatValueSettingProcessor) {
super($injector, settings);
this.isDefinedUnit = isNotEmptyStr(settings.units);
this.isDefinedDec = isDefinedAndNotNull(settings.dec);
this.showZeroDecimals = !!settings.showZeroDecimals;
}
format(value: any): string {
if (isDefinedAndNotNull(value) && isNumeric(value) && (this.isDefinedDec || this.isDefinedUnit || Number(value).toString() === value)) {
let formatted = value;
if (this.isDefinedDec) {
formatted = Number(formatted).toFixed(this.settings.dec);
}
if (!this.showZeroDecimals) {
formatted = Number(formatted)
}
formatted = formatted.toString();
if (this.isDefinedUnit) {
formatted += ` ${this.settings.units}`;
}
return formatted;
}
return value ?? '';
}
}
export class ConvertUnitProcessor extends FormatValueProcessor {
private readonly isDefinedDec: boolean;
private readonly showZeroDecimals: boolean;
private readonly unitConvertor: TbUnitConvertor;
private readonly unitAbbr: string;
constructor(protected $injector: Injector,
protected settings: FormatValueSettingProcessor) {
super($injector, settings);
const unitService = this.$injector.get(UnitService);
const userUnitSystem = unitService.getUnitSystem();
const unit = settings.units as TbUnitMapping;
const fromUnit = unit.from;
this.unitAbbr = isNotEmptyStr(unit[userUnitSystem]) ? unit[userUnitSystem] : fromUnit;
try {
this.unitConvertor = unitService.geUnitConvertor(fromUnit, this.unitAbbr);
} catch (e) {/**/}
this.isDefinedDec = isDefinedAndNotNull(settings.dec);
this.showZeroDecimals = !!settings.showZeroDecimals;
}
format(value: any): string {
if (isDefinedAndNotNull(value) && isNumeric(value)) {
let formatted: number | string = Number(value);
if (this.unitConvertor) {
formatted = this.unitConvertor(value);
}
if (this.isDefinedDec) {
formatted = Number(formatted).toFixed(this.settings.dec);
}
if (!this.showZeroDecimals) {
formatted = Number(formatted)
}
formatted = formatted.toString();
if (this.unitAbbr) {
formatted += ` ${this.unitAbbr}`;
}
return formatted;
}
return value ?? '';
}
}

2
ui-ngx/src/app/shared/models/user.model.ts

@ -20,6 +20,7 @@ import { CustomerId } from './id/customer-id';
import { Authority } from './authority.enum';
import { TenantId } from './id/tenant-id';
import { HasTenantId } from '@shared/models/entity.models';
import { UnitSystem } from '@shared/models/unit.models';
export interface User extends BaseData<UserId>, HasTenantId {
tenantId: TenantId;
@ -40,6 +41,7 @@ export interface UserAdditionalInfo {
defaultDashboardFullscreen: boolean;
homeDashboardId: string;
homeDashboardHideToolbar: boolean;
unitSystem: UnitSystem;
lang: string;
[key: string]: any;
}

2
ui-ngx/src/app/shared/shared.module.ts

@ -185,6 +185,7 @@ import { ToggleHeaderComponent, ToggleOption } from '@shared/components/toggle-h
import { RuleChainSelectComponent } from '@shared/components/rule-chain/rule-chain-select.component';
import { ToggleSelectComponent } from '@shared/components/toggle-select.component';
import { UnitInputComponent } from '@shared/components/unit-input.component';
import { ConvertUnitSettingsPanelComponent } from '@shared/components/convert-unit-settings-panel.component';
import { MaterialIconsComponent } from '@shared/components/material-icons.component';
import { ColorPickerPanelComponent } from '@shared/components/color-picker/color-picker-panel.component';
import { TbIconComponent } from '@shared/components/icon.component';
@ -413,6 +414,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ToggleOption,
ToggleSelectComponent,
UnitInputComponent,
ConvertUnitSettingsPanelComponent,
StringAutocompleteComponent,
MaterialIconsComponent,
RuleChainSelectComponent,

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

@ -5847,6 +5847,13 @@
"background-blur": "Background blur"
},
"unit": {
"unit-system": "Unit system",
"unit-system-type": {
"AUTO": "Auto",
"METRIC": "Metric",
"IMPERIAL": "Imperial",
"HYBRID": "Hybrid"
},
"millimeter": "Millimeter",
"centimeter": "Centimeter",
"angstrom": "Angstrom",

Loading…
Cancel
Save