diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index fed2de125f..7e2e5d1e52 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -491,11 +491,12 @@ export const createLabelFromSubscriptionEntityInfo = (entityInfo: SubscriptionEn export const hasDatasourceLabelsVariables = (pattern: string): boolean => varsRegex.test(pattern) !== null; -export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number): FormattedData[] { - return _(input).groupBy(el => el.datasource.entityName + el.datasource.entityType) +export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number, + groupFunction: (el: DatasourceData) => any = (el) => el.datasource.entityName + el.datasource.entityType): FormattedData[] { + return _(input).groupBy(groupFunction) .values().value().map((entityArray, i) => { - const datasource = entityArray[0].datasource; - const obj = formattedDataFromDatasource(datasource, i); + const datasource = entityArray[0].datasource as D; + const obj = formattedDataFromDatasource(datasource, i); entityArray.filter(el => el.data.length).forEach(el => { const index = isDefined(dataIndex) ? dataIndex : el.data.length - 1; const dataSet = isDefined(ts) ? el.data.find(data => data[0] === ts) : el.data[index]; @@ -537,7 +538,7 @@ export function formattedDataArrayFromDatasourceData(input: DatasourceData[]): F }); } -export function formattedDataFromDatasource(datasource: Datasource, dsIndex: number): FormattedData { +export function formattedDataFromDatasource(datasource: D, dsIndex: number): FormattedData { return { entityName: datasource.entityName, deviceName: datasource.entityName, diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 257e1bf348..9e65bdb4db 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -231,7 +231,7 @@ import * as EntityFilterViewComponent from '@home/components/entity/entity-filte import * as EntityAliasDialogComponent from '@home/components/alias/entity-alias-dialog.component'; import * as EntityFilterComponent from '@home/components/entity/entity-filter.component'; import * as RelationFiltersComponent from '@home/components/relation/relation-filters.component'; -import * as EntityAliasSelectComponent from '@home/components/alias/entity-alias-select.component'; +import * as EntityAliasSelectComponent from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component'; import * as DataKeysComponent from '@home/components/widget/config/data-keys.component'; import * as DataKeyConfigDialogComponent from '@home/components/widget/config/data-key-config-dialog.component'; import * as DataKeyConfigComponent from '@home/components/widget/config/data-key-config.component'; @@ -577,7 +577,7 @@ class ModulesMap implements IModulesMap { '@home/components/alias/entity-alias-dialog.component': EntityAliasDialogComponent, '@home/components/entity/entity-filter.component': EntityFilterComponent, '@home/components/relation/relation-filters.component': RelationFiltersComponent, - '@home/components/alias/entity-alias-select.component': EntityAliasSelectComponent, + '@home/components/widget/lib/settings/common/alias/entity-alias-select.component': EntityAliasSelectComponent, '@home/components/widget/config/data-keys.component': DataKeysComponent, '@home/components/widget/config/data-key-config-dialog.component': DataKeyConfigDialogComponent, '@home/components/widget/config/data-key-config.component': DataKeyConfigComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html index 25acce8553..d2aaf8a3ab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html @@ -16,7 +16,13 @@ --> - + +
widget-config.appearance
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts index 2028582f52..8745136894 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models'; +import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; import { FilterSelectCallbacks } from '@home/components/filter/filter-select.component.models'; import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts index b62f462576..11e3af65dd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts @@ -37,7 +37,7 @@ import { AlarmSearchStatus } from '@shared/models/alarm.models'; import { Dashboard } from '@shared/models/dashboard.models'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { IAliasController } from '@core/api/widget-api.models'; -import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models'; +import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; import { FilterSelectCallbacks } from '@home/components/filter/filter-select.component.models'; import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { EntityType } from '@shared/models/entity-type.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts index 14e3187f61..daccadbdfd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts @@ -31,7 +31,7 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com import { TargetDevice, TargetDeviceType } from '@shared/models/widget.models'; import { EntityType } from '@shared/models/entity-type.models'; import { IAliasController } from '@core/api/widget-api.models'; -import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models'; +import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts index 4470fc1327..cd0061d22b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts @@ -24,7 +24,6 @@ import { DataKeyConfigDialogComponent } from '@home/components/widget/config/dat import { DataKeyConfigComponent } from '@home/components/widget/config/data-key-config.component'; import { DatasourceComponent } from '@home/components/widget/config/datasource.component'; import { DatasourcesComponent } from '@home/components/widget/config/datasources.component'; -import { EntityAliasSelectComponent } from '@home/components/alias/entity-alias-select.component'; import { FilterSelectComponent } from '@home/components/filter/filter-select.component'; import { WidgetSettingsModule } from '@home/components/widget/lib/settings/widget-settings.module'; import { WidgetSettingsComponent } from '@home/components/widget/config/widget-settings.component'; @@ -45,7 +44,6 @@ import { TargetDeviceComponent } from '@home/components/widget/config/target-dev DatasourceComponent, DatasourcesComponent, TargetDeviceComponent, - EntityAliasSelectComponent, FilterSelectComponent, TimewindowStyleComponent, TimewindowStylePanelComponent, @@ -67,7 +65,6 @@ import { TargetDeviceComponent } from '@home/components/widget/config/target-dev DatasourceComponent, DatasourcesComponent, TargetDeviceComponent, - EntityAliasSelectComponent, FilterSelectComponent, TimewindowStyleComponent, TimewindowStylePanelComponent, 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 ec588d69ad..a1c6d2c2b4 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 @@ -23,7 +23,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { AbstractControl, UntypedFormGroup } from '@angular/forms'; -import { DataKey, DatasourceType, WidgetConfigMode, widgetType } from '@shared/models/widget.models'; +import { DataKey, DatasourceType, Widget, WidgetConfigMode, widgetType } from '@shared/models/widget.models'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { isDefinedAndNotNull } from '@core/utils'; import { IAliasController } from '@core/api/widget-api.models'; @@ -67,6 +67,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement return this.widgetConfigComponent.widgetConfigCallbacks; } + get functionsOnly(): boolean { + return this.widgetConfigComponent.functionsOnly; + } + get widgetType(): widgetType { return this.widgetConfigComponent.widgetType; } @@ -75,6 +79,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement return this.widgetConfigComponent.widgetEditMode; } + get widget(): Widget { + return this.widgetConfigComponent.widget; + } + widgetConfigChangedEmitter = new EventEmitter(); widgetConfigChanged = this.widgetConfigChangedEmitter.asObservable(); diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts index ab63e0e55d..6594e610ab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts @@ -75,6 +75,9 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, @Input() callbacks: WidgetConfigCallbacks; + @Input() + functionsOnly: boolean; + @Input() dashboard: Dashboard; @@ -140,6 +143,11 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, this.definedSettingsComponent.dataKeyCallbacks = this.callbacks; } } + if (propName === 'functionsOnly') { + if (this.definedSettingsComponent) { + this.definedSettingsComponent.functionsOnly = this.functionsOnly; + } + } if (propName === 'widgetConfig') { if (this.definedSettingsComponent) { this.definedSettingsComponent.widgetConfig = this.widgetConfig; @@ -229,6 +237,7 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, this.definedSettingsComponent = this.definedSettingsComponentRef.instance; this.definedSettingsComponent.aliasController = this.aliasController; this.definedSettingsComponent.callbacks = this.callbacks; + this.definedSettingsComponent.functionsOnly = this.functionsOnly; this.definedSettingsComponent.dataKeyCallbacks = this.callbacks; this.definedSettingsComponent.dashboard = this.dashboard; this.definedSettingsComponent.widget = this.widget; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts new file mode 100644 index 0000000000..9d47c3a272 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts @@ -0,0 +1,170 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + CirclesDataLayerSettings, + MapDataLayerSettings, mapDataSourceSettingsToDatasource, + MarkersDataLayerSettings, PolygonsDataLayerSettings, TbMapDatasource +} from '@home/components/widget/lib/maps/map.models'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { FormattedData } from '@shared/models/widget.models'; +import { Observable, of } from 'rxjs'; +import { guid } from '@core/utils'; +import L from 'leaflet'; + +abstract class TbDataLayerItem> { + + protected layer: L.Layer; + +} + +export enum MapDataLayerType { + marker = 'marker', + polygon = 'polygon', + circle = 'circle' +} + +export abstract class TbMapDataLayer { + + protected datasource: TbMapDatasource; + + protected mapDataId = guid(); + + protected constructor(protected map: TbMap, + protected settings: S) { + } + + public setup(): Observable { + this.datasource = mapDataSourceSettingsToDatasource(this.settings); + this.datasource.dataKeys = this.settings.additionalDataKeys ? [...this.settings.additionalDataKeys] : []; + this.mapDataId = this.datasource.mapDataIds[0]; + this.datasource = this.setupDatasource(this.datasource); + return this.doSetup(); + } + + public getDatasource(): TbMapDatasource { + return this.datasource; + } + + public updateData(dsData: FormattedData[]) { + const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + this.onData(layerData, dsData); + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + return datasource; + } + + public abstract dataLayerType(): MapDataLayerType; + + protected abstract doSetup(): Observable; + + protected abstract onData(layerData: FormattedData[], dsData: FormattedData[]); + +} + +export class TbMarkersDataLayer extends TbMapDataLayer { + + constructor(protected map: TbMap, + protected settings: MarkersDataLayerSettings) { + super(map, settings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.marker; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.xKey, this.settings.yKey); + return datasource; + } + + protected doSetup(): Observable { + return of(null); + } + + protected onData(layerData: FormattedData[], dsData: FormattedData[]) { + layerData.forEach((data, index) => { + console.log(`[${this.mapDataId}][${index}]: Markers layer data updated!`); + console.log(data); + this.markerData(data, dsData); + }); + } + + private markerData(data: FormattedData, dsData: FormattedData[]) { + const xKeyVal = data[this.settings.xKey.label]; + const yKeyVal = data[this.settings.yKey.label]; + } + +} + +export class TbPolygonsDataLayer extends TbMapDataLayer { + + constructor(protected map: TbMap, + protected settings: PolygonsDataLayerSettings) { + super(map, settings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.polygon; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.polygonKey); + return datasource; + } + + protected doSetup(): Observable { + return of(null); + } + + protected onData(layerData: FormattedData[], dsData: FormattedData[]) { + layerData.forEach((data, index) => { + console.log(`[${this.mapDataId}][${index}]: Polygons layer data updated!`); + console.log(data); + }); + } + +} + +export class TbCirclesDataLayer extends TbMapDataLayer { + + constructor(protected map: TbMap, + protected settings: CirclesDataLayerSettings) { + super(map, settings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.circle; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.circleKey); + return datasource; + } + + protected doSetup(): Observable { + return of(null); + } + + protected onData(layerData: FormattedData[], dsData: FormattedData[]) { + layerData.forEach((data, index) => { + console.log(`[${this.mapDataId}][${index}]: Circles layer data updated!`); + console.log(data); + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts index 48f2362f0d..fe7dde4114 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts @@ -14,11 +14,11 @@ /// limitations under the License. /// -import { DataKey, DatasourceType } from '@shared/models/widget.models'; -import { EntityType } from '@shared/models/entity-type.models'; +import { DataKey, Datasource, DatasourceType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { mergeDeep } from '@core/utils'; -import { AbstractControl, ValidationErrors } from '@angular/forms'; +import { guid, mergeDeep } from '@core/utils'; +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { materialColors } from '@shared/models/material.models'; export enum MapType { geoMap = 'geoMap', @@ -27,33 +27,133 @@ export enum MapType { export interface MapDataSourceSettings { dsType: DatasourceType; - dsEntityType?: EntityType; - dsEntityId?: string; + dsDeviceId?: string; dsEntityAliasId?: string; dsFilterId?: string; } +export interface TbMapDatasource extends Datasource { + mapDataIds: string[]; +} + +export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings): TbMapDatasource => { + return { + type: settings.dsType, + deviceId: settings.dsDeviceId, + entityAliasId: settings.dsEntityAliasId, + filterId: settings.dsFilterId, + dataKeys: [], + mapDataIds: [guid()] + }; +}; + export interface MapDataLayerSettings extends MapDataSourceSettings { additionalDataKeys?: DataKey[]; group?: string; } +export type MapDataLayerType = 'markers' | 'polygons' | 'circles'; + +export const mapDataLayerValid = (dataLayer: MapDataLayerSettings, type: MapDataLayerType): boolean => { + if (!dataLayer.dsType || ![DatasourceType.function, DatasourceType.device, DatasourceType.entity].includes(dataLayer.dsType)) { + return false; + } + switch (dataLayer.dsType) { + case DatasourceType.function: + break; + case DatasourceType.device: + if (!dataLayer.dsDeviceId) { + return false; + } + break; + case DatasourceType.entity: + if (!dataLayer.dsEntityAliasId) { + return false; + } + break; + } + switch (type) { + case 'markers': + const markersDataLayer = dataLayer as MarkersDataLayerSettings; + if (!markersDataLayer.xKey?.type || !markersDataLayer.xKey?.name || + !markersDataLayer.yKey?.type || !markersDataLayer.xKey?.name) { + return false; + } + break; + case 'polygons': + const polygonsDataLayer = dataLayer as PolygonsDataLayerSettings; + if (!polygonsDataLayer.polygonKey?.type || !polygonsDataLayer.polygonKey?.name) { + return false; + } + break; + case 'circles': + const circlesDataLayer = dataLayer as CirclesDataLayerSettings; + if (!circlesDataLayer.circleKey?.type || !circlesDataLayer.circleKey?.name) { + return false; + } + break; + } + return true; +}; + +export const mapDataLayerValidator = (type: MapDataLayerType): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const layer: MapDataLayerSettings = control.value; + if (!mapDataLayerValid(layer, type)) { + return { + layer: true + }; + } + return null; + }; +}; + export interface MarkersDataLayerSettings extends MapDataLayerSettings { xKey: DataKey; yKey: DataKey; } -export const defaultMarkersDataLayerSettings = (mapType: MapType): MarkersDataLayerSettings => ({ - dsType: DatasourceType.entity, +const defaultMarkerLatitudeFunction = 'var value = prevValue || 15.833293;\n' + + 'if (time % 5000 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerLongitudeFunction = 'var value = prevValue || -90.454350;\n' + + 'if (time % 5000 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerXPosFunction = 'var value = prevValue || 0.2;\n' + + 'if (time % 5000 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerYPosFunction = 'var value = prevValue || 0.3;\n' + + 'if (time % 5000 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly = false): MarkersDataLayerSettings => ({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, xKey: { - name: MapType.geoMap === mapType ? 'latitude' : 'xPos', + name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'latitude' : 'xPos'), label: MapType.geoMap === mapType ? 'latitude' : 'xPos', - type: DataKeyType.attribute + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + funcBody: functionsOnly ? (MapType.geoMap === mapType ? defaultMarkerLatitudeFunction : defaultMarkerXPosFunction) : undefined, + settings: {}, + color: materialColors[0].value }, yKey: { - name: MapType.geoMap === mapType ? 'longitude' : 'yPos', + name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'longitude' : 'yPos'), label: MapType.geoMap === mapType ? 'longitude' : 'yPos', - type: DataKeyType.attribute + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + funcBody: functionsOnly ? (MapType.geoMap === mapType ? defaultMarkerLongitudeFunction : defaultMarkerYPosFunction) : undefined, + settings: {}, + color: materialColors[0].value } }); @@ -61,25 +161,40 @@ export interface PolygonsDataLayerSettings extends MapDataLayerSettings { polygonKey: DataKey; } -export const defaultPolygonsDataLayerSettings: PolygonsDataLayerSettings = { - dsType: DatasourceType.entity, +export const defaultPolygonsDataLayerSettings = (functionsOnly = false): PolygonsDataLayerSettings => ({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, polygonKey: { - name: 'perimeter', + name: functionsOnly ? 'f(x)' : 'perimeter', label: 'perimeter', - type: DataKeyType.attribute + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + settings: {}, + color: materialColors[0].value } -}; +}); export interface CirclesDataLayerSettings extends MapDataLayerSettings { circleKey: DataKey; } -export const defaultCirclesDataLayerSettings: CirclesDataLayerSettings = { - dsType: DatasourceType.entity, +export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesDataLayerSettings => ({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, circleKey: { - name: 'perimeter', + name: functionsOnly ? 'f(x)' : 'perimeter', label: 'perimeter', - type: DataKeyType.attribute + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + settings: {}, + color: materialColors[0].value + } +}); + +export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => { + switch (dataLayerType) { + case 'markers': + return defaultMarkersDataLayerSettings(mapType, functionsOnly); + case 'polygons': + return defaultPolygonsDataLayerSettings(functionsOnly); + case 'circles': + return defaultCirclesDataLayerSettings(functionsOnly); } }; @@ -87,6 +202,14 @@ export interface AdditionalMapDataSourceSettings extends MapDataSourceSettings { dataKeys: DataKey[]; } +export const additionalMapDataSourcesToDatasources = (additionalMapDataSources: AdditionalMapDataSourceSettings[]): TbMapDatasource[] => { + return additionalMapDataSources.map(addDs => { + const res = mapDataSourceSettingsToDatasource(addDs); + res.dataKeys = addDs.dataKeys; + return res; + }); +}; + export enum MapControlsPosition { topleft = 'topleft', topright = 'topright', @@ -428,3 +551,51 @@ export function parseCenterPosition(position: string | [number, number]): [numbe } return [0, 0]; } + +export const mergeMapDatasources = (target: TbMapDatasource[], source: TbMapDatasource[]): TbMapDatasource[] => { + const appendDatasources: TbMapDatasource[] = []; + for (const sourceDs of source) { + let merged = false; + for (let i = 0; i < target.length; i++) { + const targetDs = target[i]; + if (mapDatasourceIsSame(targetDs, sourceDs)) { + target[i] = mergeMapDatasource(targetDs, sourceDs); + merged = true; + break; + } + } + if (!merged) { + appendDatasources.push(sourceDs); + } + } + target.push(...appendDatasources); + return target; +}; + +const mapDatasourceIsSame = (ds1: TbMapDatasource, ds2: TbMapDatasource): boolean => { + if (ds1.type === ds2.type) { + switch (ds1.type) { + case DatasourceType.function: + return true; + case DatasourceType.device: + return ds1.deviceId === ds2.deviceId; + case DatasourceType.entity: + return ds1.entityAliasId === ds2.entityAliasId; + } + } + return false; +} + +const mergeMapDatasource = (target: TbMapDatasource, source: TbMapDatasource): TbMapDatasource => { + target.mapDataIds.push(...source.mapDataIds); + const appendKeys: DataKey[] = []; + for (const sourceKey of source.dataKeys) { + const found = + target.dataKeys.find(key => key.type === sourceKey.type && key.name === sourceKey.name && key.label === sourceKey.label); + if (!found) { + appendKeys.push(sourceKey); + } + } + target.dataKeys.push(...appendKeys); + return target; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index cf61fc34ef..c664418ce8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -15,6 +15,7 @@ /// import { + additionalMapDataSourcesToDatasources, BaseMapSettings, DEFAULT_ZOOM_LEVEL, defaultGeoMapSettings, @@ -23,17 +24,27 @@ import { ImageMapSettings, MapSetting, MapType, - MapZoomAction, - parseCenterPosition + MapZoomAction, mergeMapDatasources, + parseCenterPosition, TbMapDatasource } from '@home/components/widget/lib/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; -import { mergeDeep, mergeDeepIgnoreArray } from '@core/utils'; +import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; import { map, switchMap, tap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; +import { + MapDataLayerType, + TbCirclesDataLayer, + TbMapDataLayer, + TbMarkersDataLayer, + TbPolygonsDataLayer +} from '@home/components/widget/lib/maps/map-data-layer'; +import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { FormattedData, widgetType } from '@shared/models/widget.models'; +import { EntityDataPageLink } from '@shared/models/query/query.models'; export abstract class TbMap { @@ -54,6 +65,8 @@ export abstract class TbMap { protected defaultCenterPosition: [number, number]; protected bounds: L.LatLngBounds; + protected dataLayers: TbMapDataLayer[]; + protected mapElement: HTMLElement; private readonly mapResize$: ResizeObserver; @@ -112,6 +125,73 @@ export abstract class TbMap { } else { this.bounds = new L.LatLngBounds(null, null); } + this.setupDataLayers(); + } + + private setupDataLayers() { + this.dataLayers = []; + if (this.settings.markers) { + this.dataLayers.push(...this.settings.markers.map(settings => new TbMarkersDataLayer(this, settings))); + } + if (this.settings.polygons) { + this.dataLayers.push(...this.settings.polygons.map(settings => new TbPolygonsDataLayer(this, settings))); + } + if (this.settings.circles) { + this.dataLayers.push(...this.settings.circles.map(settings => new TbCirclesDataLayer(this, settings))); + } + if (this.dataLayers.length) { + const setup = this.dataLayers.map(dl => dl.setup()); + forkJoin(setup).subscribe( + () => { + let datasources: TbMapDatasource[]; + for (const layerType of (Object.keys(MapDataLayerType) as MapDataLayerType[])) { + const typeDatasources = this.dataLayers.filter(dl => dl.dataLayerType() === layerType).map(dl => dl.getDatasource()); + if (!datasources) { + datasources = typeDatasources; + } else { + datasources = mergeMapDatasources(datasources, typeDatasources); + } + } + const additionalDatasources = additionalMapDataSourcesToDatasources(this.settings.additionalDataSources); + datasources = mergeMapDatasources(datasources, additionalDatasources); + const dataLayersSubscriptionOptions: WidgetSubscriptionOptions = { + datasources, + hasDataPageLink: true, + useDashboardTimewindow: false, + type: widgetType.latest, + callbacks: { + onDataUpdated: (subscription) => { + this.update(subscription); + } + } + }; + this.ctx.subscriptionApi.createSubscription(dataLayersSubscriptionOptions, false).subscribe( + (dataLayersSubscription) => { + let pageSize = this.settings.mapPageSize; + if (isDefinedAndNotNull(this.ctx.widgetConfig.pageSize)) { + pageSize = Math.max(pageSize, this.ctx.widgetConfig.pageSize); + } + const pageLink: EntityDataPageLink = { + page: 0, + pageSize, + textSearch: null, + dynamic: true + }; + dataLayersSubscription.paginatedDataSubscriptionUpdated.subscribe(() => { + // this.map.resetState(); + }); + dataLayersSubscription.subscribeAllForPaginatedData(pageLink, null); + } + ); + } + ); + } + } + + private update(subscription: IWidgetSubscription) { + const dsData = formattedDataFormDatasourceData(subscription.data, + undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); + this.dataLayers.forEach(dl => dl.updateData(dsData)); } private resize() { diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html similarity index 56% rename from ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html index 008d1aa9fa..db71e367e8 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html @@ -15,9 +15,14 @@ limitations under the License. --> - - {{ 'entity.entity-alias' | translate }} - + {{ 'entity.entity-alias' | translate }} + - - - + + + warning + +
+ +
+ +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss new file mode 100644 index 0000000000..d4ebdbaeed --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-form-table-row.tb-map-data-layer-row { + + .tb-source-field { + flex: 1 1 50%; + display: flex; + gap: 12px; + .tb-ds-type-field, .tb-device-field, .tb-entity-alias-field { + flex: 1; + } + } + + .tb-x-pos-field { + flex: 1 1 25%; + } + + .tb-y-pos-field { + flex: 1 1 25%; + } + + .tb-key-field { + flex: 1 1 50%; + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts new file mode 100644 index 0000000000..1d9d2014e2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts @@ -0,0 +1,380 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { TranslateService } from '@ngx-translate/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + CirclesDataLayerSettings, + MapDataLayerSettings, + MapDataLayerType, + MapType, + MarkersDataLayerSettings, + PolygonsDataLayerSettings +} from '@home/components/widget/lib/maps/map.models'; +import { + DataKey, + DataKeyConfigMode, + DatasourceType, + datasourceTypeTranslationMap, + widgetType +} from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; +import { IAliasController } from '@core/api/widget-api.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { + DataKeyConfigDialogComponent, + DataKeyConfigDialogData +} from '@home/components/widget/config/data-key-config-dialog.component'; +import { deepClone } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; +import { + EntityAliasSelectCallbacks +} from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; + +@Component({ + selector: 'tb-map-data-layer-row', + templateUrl: './map-data-layer-row.component.html', + styleUrls: ['./map-data-layer-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataLayerRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataLayerRowComponent implements ControlValueAccessor, OnInit { + + DatasourceType = DatasourceType; + DataKeyType = DataKeyType; + + EntityType = EntityType; + + MapType = MapType; + + datasourceTypes: Array = []; + datasourceTypesTranslations = datasourceTypeTranslationMap; + + @Input() + disabled: boolean; + + @Input() + mapType: MapType = MapType.geoMap; + + @Input() + dataLayerType: MapDataLayerType = 'markers'; + + get functionsOnly(): boolean { + return this.mapSettingsComponent.functionsOnly; + } + + get aliasController(): IAliasController { + return this.mapSettingsComponent.aliasController; + } + + get dataKeyCallbacks(): DataKeysCallbacks { + return this.mapSettingsComponent.callbacks; + } + + public get entityAliasSelectCallbacks(): EntityAliasSelectCallbacks { + return this.mapSettingsComponent.callbacks; + } + + @Output() + dataLayerRemoved = new EventEmitter(); + + generateDataKey = this._generateDataKey.bind(this); + + dataLayerFormGroup: UntypedFormGroup; + + modelValue: MapDataLayerSettings; + + editDataLayerText: string; + + removeDataLayerText: string; + + private propagateChange = (_val: any) => {}; + + constructor(private mapSettingsComponent: MapSettingsComponent, + private fb: UntypedFormBuilder, + private dialog: MatDialog, + private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + if (this.functionsOnly) { + this.datasourceTypes = [DatasourceType.function]; + } else { + this.datasourceTypes = [DatasourceType.function, DatasourceType.device, DatasourceType.entity]; + } + this.dataLayerFormGroup = this.fb.group({ + dsType: [null, [Validators.required]], + dsDeviceId: [null, [Validators.required]], + dsEntityAliasId: [null, [Validators.required]] + }); + switch (this.dataLayerType) { + case 'markers': + this.editDataLayerText = 'widgets.maps.data-layer.marker.marker-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.marker.remove-marker'; + this.dataLayerFormGroup.addControl('xKey', this.fb.control(null, Validators.required)); + this.dataLayerFormGroup.addControl('yKey', this.fb.control(null, Validators.required)); + break; + case 'polygons': + this.editDataLayerText = 'widgets.maps.data-layer.polygon.polygon-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.polygon.remove-polygon'; + this.dataLayerFormGroup.addControl('polygonKey', this.fb.control(null, Validators.required)); + break; + case 'circles': + this.editDataLayerText = 'widgets.maps.data-layer.circle.circle-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.circle.remove-circle'; + this.dataLayerFormGroup.addControl('circleKey', this.fb.control(null, Validators.required)); + break; + } + this.dataLayerFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => this.updateModel() + ); + this.dataLayerFormGroup.get('dsType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (newDsType: DatasourceType) => this.onDsTypeChanged(newDsType) + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataLayerFormGroup.disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: MapDataLayerSettings): void { + this.modelValue = value; + this.dataLayerFormGroup.patchValue( + { + dsType: value?.dsType, + dsDeviceId: value?.dsDeviceId, + dsEntityAliasId: value?.dsEntityAliasId + }, {emitEvent: false} + ); + switch (this.dataLayerType) { + case 'markers': + const markersDataLayer = value as MarkersDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + xKey: markersDataLayer?.xKey, + yKey: markersDataLayer?.yKey + }, {emitEvent: false} + ); + break; + case 'polygons': + const polygonsDataLayer = value as PolygonsDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + polygonKey: polygonsDataLayer?.polygonKey + }, {emitEvent: false} + ); + break; + case 'circles': + const circlesDataLayer = value as CirclesDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + circleKey: circlesDataLayer?.circleKey + }, {emitEvent: false} + ); + break; + } + this.updateValidators(); + this.cd.markForCheck(); + } + + editKey(keyType: 'xKey' | 'yKey' | 'polygonKey' | 'circleKey') { + const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value; + this.dialog.open(DataKeyConfigDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dataKey: deepClone(targetDataKey), + dataKeyConfigMode: DataKeyConfigMode.general, + aliasController: this.aliasController, + widgetType: widgetType.latest, + deviceId: this.dataLayerFormGroup.get('dsDeviceId').value, + entityAliasId: this.dataLayerFormGroup.get('dsEntityAliasId').value, + showPostProcessing: true, + callbacks: this.mapSettingsComponent.callbacks, + hideDataKeyColor: true, + hideDataKeyDecimals: true, + hideDataKeyUnits: true, + widget: this.mapSettingsComponent.widget, + dashboard: null, + dataKeySettingsForm: null, + dataKeySettingsDirective: null + } + }).afterClosed().subscribe((updatedDataKey) => { + if (updatedDataKey) { + this.dataLayerFormGroup.get(keyType).patchValue(updatedDataKey); + } + }); + } + + editDataLayer($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + /*const ctx: any = { + mapLayerSettings: deepClone(this.modelValue) + }; + const mapLayerSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MapLayerSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + mapLayerSettingsPanelPopover.tbComponentRef.instance.popover = mapLayerSettingsPanelPopover; + mapLayerSettingsPanelPopover.tbComponentRef.instance.mapLayerSettingsApplied.subscribe((layer) => { + mapLayerSettingsPanelPopover.hide(); + this.layerFormGroup.patchValue( + layer, + {emitEvent: false}); + this.updateValidators(); + this.updateModel(); + });*/ + } + } + + private _generateDataKey(key: DataKey): DataKey { + key = this.dataKeyCallbacks.generateDataKey(key.name, key.type, null, false, + null); + return key; + } + + private onDsTypeChanged(newDsType: DatasourceType) { + let updateModel = false; + switch (this.dataLayerType) { + case 'markers': + const xKey: DataKey = this.dataLayerFormGroup.get('xKey').value; + if (this.updateDataKeyToNewDsType(xKey, newDsType)) { + this.dataLayerFormGroup.get('xKey').patchValue(xKey, {emitEvent: false}); + updateModel = true; + } + const yKey: DataKey = this.dataLayerFormGroup.get('yKey').value; + if (this.updateDataKeyToNewDsType(yKey, newDsType)) { + this.dataLayerFormGroup.get('yKey').patchValue(yKey, {emitEvent: false}); + updateModel = true; + } + break; + case 'polygons': + const polygonKey: DataKey = this.dataLayerFormGroup.get('polygonKey').value; + if (this.updateDataKeyToNewDsType(polygonKey, newDsType)) { + this.dataLayerFormGroup.get('polygonKey').patchValue(polygonKey, {emitEvent: false}); + updateModel = true; + } + break; + case 'circles': + const circleKey: DataKey = this.dataLayerFormGroup.get('circleKey').value; + if (this.updateDataKeyToNewDsType(circleKey, newDsType)) { + this.dataLayerFormGroup.get('circleKey').patchValue(circleKey, {emitEvent: false}); + updateModel = true; + } + break; + } + this.updateValidators(); + if (updateModel) { + this.updateModel(); + } + } + + private updateDataKeyToNewDsType(dataKey: DataKey, newDsType: DatasourceType): boolean { + if (newDsType === DatasourceType.function) { + if (dataKey.type !== DataKeyType.function) { + dataKey.type = DataKeyType.function; + return true; + } + } else { + if (dataKey.type === DataKeyType.function) { + dataKey.type = DataKeyType.attribute; + return true; + } + } + return false; + } + + private updateValidators() { + const dsType: DatasourceType = this.dataLayerFormGroup.get('dsType').value; + if (dsType === DatasourceType.function) { + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else if (dsType === DatasourceType.device) { + this.dataLayerFormGroup.get('dsDeviceId').enable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = {...this.modelValue, ...this.dataLayerFormGroup.value}; + this.propagateChange(this.modelValue); + } + + protected readonly datasourceType = DatasourceType; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html new file mode 100644 index 0000000000..0c19ff6a9b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html @@ -0,0 +1,51 @@ + +
+
+
+
widgets.maps.data-layer.source
+
+ {{ (mapType === MapType.geoMap ? 'widgets.maps.data-layer.marker.latitude-key' : 'widgets.maps.data-layer.marker.x-pos-key') | translate }} +
+
+ {{ (mapType === MapType.geoMap ? 'widgets.maps.data-layer.marker.longitude-key' : 'widgets.maps.data-layer.marker.y-pos-key') | translate }} +
+
widgets.maps.data-layer.polygon.polygon-key
+
widgets.maps.data-layer.circle.circle-key
+
+
+
+
+ + +
+
+
+
+ +
+
+ + {{ noDataLayersText | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss new file mode 100644 index 0000000000..7795f9de6e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-map-data-layers { + .tb-form-table-header-cell { + &.tb-source-header { + flex: 1 1 50%; + } + &.tb-x-pos-header { + flex: 1 1 25%; + } + &.tb-y-pos-header { + flex: 1 1 25%; + } + &.tb-key-header { + flex: 1 1 50%; + } + &.tb-actions-header { + width: 80px; + min-width: 80px; + } + } + + .tb-form-table-body { + tb-map-data-layer-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts new file mode 100644 index 0000000000..5c8aa5534d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts @@ -0,0 +1,177 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { mergeDeep } from '@core/utils'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + defaultMapDataLayerSettings, + MapDataLayerSettings, + MapDataLayerType, + mapDataLayerValid, + mapDataLayerValidator, + MapType +} from '@home/components/widget/lib/maps/map.models'; +import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; + +@Component({ + selector: 'tb-map-data-layers', + templateUrl: './map-data-layers.component.html', + styleUrls: ['./map-data-layers.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataLayersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapDataLayersComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Validator { + + MapType = MapType; + + @Input() + disabled: boolean; + + @Input() + mapType: MapType = MapType.geoMap; + + @Input() + dataLayerType: MapDataLayerType = 'markers'; + + get functionsOnly(): boolean { + return this.mapSettingsComponent.functionsOnly; + } + + dataLayersFormGroup: UntypedFormGroup; + + addDataLayerText: string; + + noDataLayersText: string; + + private propagateChange = (_val: any) => {}; + + constructor(private mapSettingsComponent: MapSettingsComponent, + private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + switch (this.dataLayerType) { + case 'markers': + this.addDataLayerText = 'widgets.maps.data-layer.marker.add-marker'; + this.noDataLayersText = 'widgets.maps.data-layer.marker.no-markers'; + break; + case 'polygons': + this.addDataLayerText = 'widgets.maps.data-layer.polygon.add-polygon'; + this.noDataLayersText = 'widgets.maps.data-layer.polygon.no-polygons'; + break; + case 'circles': + this.addDataLayerText = 'widgets.maps.data-layer.circle.add-circle'; + this.noDataLayersText = 'widgets.maps.data-layer.circle.no-circles'; + break; + } + this.dataLayersFormGroup = this.fb.group({ + dataLayers: [this.fb.array([]), []] + }); + this.dataLayersFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + let layers: MapDataLayerSettings[] = this.dataLayersFormGroup.get('dataLayers').value; + if (layers) { + layers = layers.filter(layer => mapDataLayerValid(layer, this.dataLayerType)); + } + this.propagateChange(layers); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataLayersFormGroup.disable({emitEvent: false}); + } else { + this.dataLayersFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: MapDataLayerSettings[] | undefined): void { + const dataLayers: MapDataLayerSettings[] = value || []; + this.dataLayersFormGroup.setControl('dataLayers', this.prepareDataLayersFormArray(dataLayers), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.dataLayersFormGroup.valid; + return valid ? null : { + dataLayers: { + valid: false, + }, + }; + } + + dataLayersFormArray(): UntypedFormArray { + return this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray; + } + + trackByDataLayer(index: number, dataLayerControl: AbstractControl): any { + return dataLayerControl; + } + + removeDataLayer(index: number) { + (this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray).removeAt(index); + } + + addDataLayer() { + const dataLayer = mergeDeep({} as MapDataLayerSettings, + defaultMapDataLayerSettings(this.mapType, this.dataLayerType, this.functionsOnly)); + const dataLayersArray = this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray; + const dataLayerControl = this.fb.control(dataLayer, [mapDataLayerValidator(this.dataLayerType)]); + dataLayersArray.push(dataLayerControl); + } + + private prepareDataLayersFormArray(dataLayers: MapDataLayerSettings[]): UntypedFormArray { + const dataLayersControls: Array = []; + dataLayers.forEach((dataLayer) => { + dataLayersControls.push(this.fb.control(dataLayer, [mapDataLayerValidator(this.dataLayerType)])); + }); + return this.fb.array(dataLayersControls); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index ec606c3736..9652daaf7b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -34,12 +34,24 @@
{{ 'widgets.maps.overlays.overlays' | translate }}
- {{ 'widgets.maps.overlays.markers' | translate }} {{ 'widgets.maps.overlays.polygons' | translate }} {{ 'widgets.maps.overlays.circles' | translate }} + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index bdf24c4549..864a0f3ea9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -22,9 +22,14 @@ import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validator } from '@angular/forms'; -import { ImageSourceType, MapSetting, MapType } from '@home/components/widget/lib/maps/map.models'; +import { ImageSourceType, MapDataLayerType, MapSetting, MapType } from '@home/components/widget/lib/maps/map.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { merge } from 'rxjs'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { IAliasController } from '@core/api/widget-api.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; +import { Widget } from '@shared/models/widget.models'; @Component({ selector: 'tb-map-settings', @@ -50,13 +55,26 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid @Input() disabled: boolean; + @Input() + @coerceBoolean() + functionsOnly = false; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: WidgetConfigCallbacks; + + @Input() + widget: Widget; + private modelValue: MapSetting; private propagateChange = null; public mapSettingsFormGroup: UntypedFormGroup; - overlaysMode: 'markers' | 'polygons' | 'circles' = 'markers'; + dataLayerMode: MapDataLayerType = 'markers'; constructor(private fb: UntypedFormBuilder, private destroyRef: DestroyRef) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index e9279d853c..7018862d08 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -189,6 +189,12 @@ import { MapLayerRowComponent } from '@home/components/widget/lib/settings/commo import { MapLayerSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/map-layer-settings-panel.component'; +import { MapDataLayersComponent } from '@home/components/widget/lib/settings/common/map/map-data-layers.component'; +import { MapDataLayerRowComponent } from '@home/components/widget/lib/settings/common/map/map-data-layer-row.component'; +import { WidgetConfigComponentsModule } from '@home/components/widget/config/widget-config-components.module'; +import { + EntityAliasSelectComponent +} from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component'; @NgModule({ declarations: [ @@ -262,7 +268,10 @@ import { MapLayerSettingsPanelComponent, MapLayerRowComponent, MapLayersComponent, - MapSettingsComponent + MapDataLayerRowComponent, + MapDataLayersComponent, + MapSettingsComponent, + EntityAliasSelectComponent ], imports: [ CommonModule, @@ -337,7 +346,8 @@ import { DynamicFormSelectItemRowComponent, DynamicFormComponent, DynamicFormArrayComponent, - MapSettingsComponent + MapSettingsComponent, + EntityAliasSelectComponent ], providers: [ ColorSettingsComponentService, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html index a2c998ac8c..d34506c9cc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html @@ -16,7 +16,13 @@ --> - + +
widget-config.card-appearance
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 80b41c012e..9e2b1bf1fb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -303,6 +303,7 @@ - - {{ label | translate }} + {{ label | translate }} {{ 'entity.create-new' | translate }} + + warning + @@ -59,7 +75,7 @@
- + {{ requiredErrorText | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index 417fc35bf4..a2920c9822 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -116,6 +116,9 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @Input() requiredText: string; + @Input() + placeholder: string; + @Input() @coerceBoolean() useFullEntityId: boolean; @@ -123,6 +126,10 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + @coerceBoolean() + inlineField: boolean; + @Input() @coerceBoolean() required: boolean; diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index e5e125e171..bbb462d9af 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -510,8 +510,8 @@ export const datasourcesHasOnlyComparisonAggregation = (datasources?: Array { + $datasource: D; entityName: string; deviceName: string; entityId: string; @@ -861,6 +861,7 @@ export interface IWidgetSettingsComponent { aliasController: IAliasController; callbacks: WidgetConfigCallbacks; dataKeyCallbacks: DataKeysCallbacks; + functionsOnly: boolean; dashboard: Dashboard; widget: Widget; widgetConfig: WidgetConfigComponentData; @@ -882,6 +883,8 @@ export abstract class WidgetSettingsComponent extends PageComponent implements dataKeyCallbacks: DataKeysCallbacks; + functionsOnly: boolean; + dashboard: Dashboard; widget: Widget; 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 da5f80d49f..946f338ddd 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2332,6 +2332,7 @@ "alias-required": "Entity alias is required.", "remove-alias": "Remove entity alias", "add-alias": "Add entity alias", + "edit-alias": "Edit entity alias", "entity-list": "Entity list", "entity-type": "Entity type", "entity-types": "Entity types", @@ -6866,6 +6867,39 @@ "polygons": "Polygons", "circles": "Circles" }, + "data-layer": { + "source": "Source", + "marker": { + "latitude-key": "Latitude key", + "longitude-key": "Longitude key", + "x-pos-key": "X position key", + "y-pos-key": "Y position key", + "latitude-key-required": "Latitude key required", + "longitude-key-required": "Longitude key required", + "x-pos-key-required": "X position key required", + "y-pos-key-required": "Y position key required", + "no-markers": "No markers configured", + "add-marker": "Add marker", + "marker-configuration": "Marker configuration", + "remove-marker": "Remove marker" + }, + "polygon": { + "polygon-key": "Polygon key", + "polygon-key-required": "Polygon key required", + "no-polygons": "No polygons configured", + "add-polygon": "Add polygon", + "polygon-configuration": "Polygon configuration", + "remove-polygon": "Remove polygon" + }, + "circle": { + "circle-key": "Circle key", + "circle-key-required": "Circle key required", + "no-circles": "No circles configured", + "add-circle": "Add circle", + "circle-configuration": "Circle configuration", + "remove-circle": "Remove circle" + } + }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position", "tooltips": {