From 304fd26be497af0eeb5d2c46ae37771d2a055d49 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 14 Jan 2025 17:40:22 +0200 Subject: [PATCH] UI: Map data layer appearance. --- .../widget/lib/maps/map-data-layer.ts | 320 +++++++++++++++--- .../components/widget/lib/maps/map.models.ts | 159 ++++++++- .../home/components/widget/lib/maps/map.ts | 80 ++++- ...data-layer-pattern-settings.component.html | 86 +++++ .../data-layer-pattern-settings.component.ts | 180 ++++++++++ .../map/map-data-layer-dialog.component.html | 31 ++ .../map/map-data-layer-dialog.component.ts | 35 +- .../map/map-data-layer-row.component.html | 5 + .../map/map-data-layer-row.component.scss | 2 +- .../map/map-data-layer-row.component.ts | 5 + .../common/widget-settings-common.module.ts | 4 + .../assets/locale/locale.constant-en_US.json | 21 +- 12 files changed, 846 insertions(+), 82 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts 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 index 2b825e09be..0cabfe9fef 100644 --- 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 @@ -19,6 +19,9 @@ import { createColorMarkerURI, DataLayerColorSettings, DataLayerColorType, + DataLayerPatternSettings, + DataLayerPatternType, + DataLayerTooltipTrigger, defaultBaseCirclesDataLayerSettings, defaultBaseMarkersDataLayerSettings, defaultBasePolygonsDataLayerSettings, @@ -37,14 +40,15 @@ import { MarkerImageType, MarkersDataLayerSettings, MarkerType, - PolygonsDataLayerSettings, + PolygonsDataLayerSettings, processTooltipTemplate, ShapeDataLayerSettings, TbCircleData, 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 { Datasource, FormattedData } from '@shared/models/widget.models'; +import { forkJoin, Observable, of } from 'rxjs'; import { + createLabelFromPattern, guid, isDefined, isDefinedAndNotNull, @@ -55,37 +59,98 @@ import { parseTbFunction, safeExecuteTbFunction } from '@core/utils'; -import L, { LatLngBounds } from 'leaflet'; +import L, { LatLngBounds, PathOptions } from 'leaflet'; import { CompiledTbFunction } from '@shared/models/js-function.models'; import { catchError, map } from 'rxjs/operators'; import tinycolor from 'tinycolor2'; import { WidgetContext } from '@home/models/widget-component.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; abstract class TbDataLayerItem> { protected layer: L.Layer; + protected tooltip: L.Popup; - constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: S, - protected dataLayer: L) { + protected constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: S, + protected dataLayer: L) { this.layer = this.create(data, dsData); + if (this.settings.tooltip?.show) { + this.createTooltip(data.$datasource); + this.updateTooltip(data, dsData); + } + this.createEventListeners(data, dsData); this.dataLayer.getFeatureGroup().addLayer(this.layer); } protected abstract create(data: FormattedData, dsData: FormattedData[]): L.Layer; + protected abstract unbindLabel(): void; + + protected abstract bindLabel(content: L.Content): void; + + protected abstract createEventListeners(data: FormattedData, dsData: FormattedData[]): void; + public abstract update(data: FormattedData, dsData: FormattedData[]): void; public remove() { + this.layer.off(); this.dataLayer.getFeatureGroup().removeLayer(this.layer); } - protected updateLayer(newLayer: L.Layer) { - this.dataLayer.getFeatureGroup().removeLayer(this.layer); - this.layer = newLayer; - this.dataLayer.getFeatureGroup().addLayer(this.layer); + protected updateTooltip(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.tooltip.show) { + let tooltipTemplate = this.dataLayer.dataLayerTooltipProcessor.processPattern(data, dsData); + tooltipTemplate = processTooltipTemplate(tooltipTemplate); + this.tooltip.setContent(tooltipTemplate); + if (this.tooltip.isOpen() && this.tooltip.getElement()) { + this.bindTooltipActions(data.$datasource); + } + } + } + + protected updateLabel(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.label.show) { + this.unbindLabel(); + const label = this.dataLayer.dataLayerLabelProcessor.processPattern(data, dsData); + const labelColor = this.dataLayer.getCtx().widgetConfig.color; + const content: L.Content = `
${label}
`; + this.bindLabel(content); + } + } + + private createTooltip(datasource: TbMapDatasource) { + this.tooltip = L.popup(); + this.layer.bindPopup(this.tooltip, {autoClose: this.settings.tooltip.autoclose, closeOnClick: false}); + if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.hover) { + this.layer.off('click'); + this.layer.on('mouseover', () => { + this.layer.openPopup(); + }); + this.layer.on('mousemove', (e) => { + this.tooltip.setLatLng(e.latlng); + }); + this.layer.on('mouseout', () => { + this.layer.closePopup(); + }); + } + this.layer.on('popupopen', () => { + this.bindTooltipActions(datasource); + (this.layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { + event.preventDefault(); + }); + }); + } + + private bindTooltipActions(datasource: TbMapDatasource) { + const actions = this.tooltip.getElement().getElementsByClassName('tb-custom-action'); + Array.from(actions).forEach( + (element: HTMLElement) => { + const actionName = element.getAttribute('data-action-name'); + this.dataLayer.getMap().tooltipElementClick(element, actionName, datasource); + }); } } @@ -96,6 +161,76 @@ export enum MapDataLayerType { circle = 'circle' } +class DataLayerPatternProcessor { + + private patternFunction: CompiledTbFunction; + private pattern: string; + + constructor(private dataLayer: TbMapDataLayer, + private settings: DataLayerPatternSettings) {} + + public setup(): Observable { + if (this.settings.type === DataLayerPatternType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.patternFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.patternFunction = parsed; + return null; + }) + ); + } else { + this.pattern = this.settings.pattern; + return of(null) + } + } + + public processPattern(data: FormattedData, dsData: FormattedData[]): string { + let pattern: string; + if (this.settings.type === DataLayerPatternType.function) { + pattern = safeExecuteTbFunction(this.patternFunction, [data, dsData]); + } else { + pattern = this.pattern; + } + const text = createLabelFromPattern(pattern, data); + const customTranslate = this.dataLayer.getCtx().$injector.get(CustomTranslatePipe); + return customTranslate.transform(text); + } + +} + +class DataLayerColorProcessor { + + private colorFunction: CompiledTbFunction; + private color: string; + + constructor(private dataLayer: TbMapDataLayer, + private settings: DataLayerColorSettings) {} + + public setup(): Observable { + if (this.settings.type === DataLayerColorType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.colorFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.colorFunction = parsed; + return null; + }) + ); + } else { + this.color = this.settings.color; + return of(null) + } + } + + public processColor(data: FormattedData, dsData: FormattedData[]): string { + let color: string; + if (this.settings.type === DataLayerColorType.function) { + color = safeExecuteTbFunction(this.colorFunction, [data, dsData]); + } else { + color = this.color; + } + return color; + } + +} + export abstract class TbMapDataLayer> implements L.TB.DataLayer { protected settings: S; @@ -112,6 +247,9 @@ export abstract class TbMapDataLayer, inputSettings: S) { this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings() as S, inputSettings); @@ -120,15 +258,22 @@ export abstract class TbMapDataLayer { + 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(); + return forkJoin( + [ + this.dataLayerLabelProcessor ? this.dataLayerLabelProcessor.setup() : of(null), + this.dataLayerTooltipProcessor ? this.dataLayerTooltipProcessor.setup() : of(null), + this.doSetup() + ]); } public getDatasource(): TbMapDatasource { @@ -192,6 +337,9 @@ export abstract class TbMapDataLayer { + return this.map; + } protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { return datasource; @@ -205,7 +353,7 @@ export abstract class TbMapDataLayer; - protected abstract doSetup(): Observable; + protected abstract doSetup(): Observable; protected abstract isValidLayerData(layerData: FormattedData): boolean; @@ -236,12 +384,26 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): void { + this.dataLayer.getMap().markerClick(this.marker, data.$datasource); + } + + protected unbindLabel() { + this.marker.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset }); + } + public update(data: FormattedData, dsData: FormattedData[]): void { const position = this.dataLayer.extractLocation(data); if (!this.marker.getLatLng().equals(position)) { this.location = position; this.marker.setLatLng(position); } + this.updateTooltip(data, dsData); this.updateMarkerIcon(data, dsData); } @@ -255,15 +417,10 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]) { - - } - } abstract class MarkerIconProcessor { @@ -448,11 +605,10 @@ export class TbMarkersDataLayer extends TbMapDataLayer { + private polygonContainer: L.FeatureGroup; private polygon: L.Polygon; constructor(data: FormattedData, @@ -532,29 +689,41 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): L.Layer { const polyData = this.dataLayer.extractPolygonCoordinates(data); const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; + const style = this.dataLayer.getShapeStyle(data, dsData); this.polygon = polyConstructor(polyData, { - fill: true, - fillColor: '#3a77e7', - color: '#0742ad', - weight: 1, - fillOpacity: 0.4, - opacity: 1 + ...style }); - return this.polygon; + + this.polygonContainer = L.featureGroup(); + this.polygon.addTo(this.polygonContainer); + + this.updateLabel(data, dsData); + return this.polygonContainer; + } + + protected createEventListeners(data: FormattedData, dsData: FormattedData[]): void { + this.dataLayer.getMap().polygonClick(this.polygonContainer, data.$datasource); + } + + protected unbindLabel() { + this.polygonContainer.unbindTooltip(); } + + protected bindLabel(content: L.Content): void { + this.polygonContainer.bindTooltip(content, {className: 'tb-polygon-label', permanent: true, direction: 'center'}) + .openTooltip(this.polygonContainer.getBounds().getCenter()); + } + public update(data: FormattedData, dsData: FormattedData[]): void { const polyData = this.dataLayer.extractPolygonCoordinates(data); + const style = this.dataLayer.getShapeStyle(data, dsData); if (isCutPolygon(polyData) || polyData.length !== 2) { if (this.polygon instanceof L.Rectangle) { + this.polygonContainer.removeLayer(this.polygon); this.polygon = L.polygon(polyData, { - fill: true, - fillColor: '#3a77e7', - color: '#0742ad', - weight: 1, - fillOpacity: 0.4, - opacity: 1 + ...style }); - this.updateLayer(this.polygon); + this.polygon.addTo(this.polygonContainer); } else { this.polygon.setLatLngs(polyData); } @@ -563,10 +732,44 @@ class TbPolygonDataLayerItem extends TbDataLayerItem> extends TbMapDataLayer { + + public fillColorProcessor: DataLayerColorProcessor; + public strokeColorProcessor: DataLayerColorProcessor; + + protected constructor(protected map: TbMap, + inputSettings: S) { + super(map, inputSettings); + } + + protected doSetup(): Observable { + this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor); + this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor); + return forkJoin([this.fillColorProcessor.setup(), this.strokeColorProcessor.setup()]); + } + + public getShapeStyle(data: FormattedData, dsData: FormattedData[]): L.PathOptions { + const fill = this.fillColorProcessor.processColor(data, dsData); + const stroke = this.strokeColorProcessor.processColor(data, dsData); + const style: L.PathOptions = { + fill: true, + fillColor: fill, + color: stroke, + weight: this.settings.strokeWeight, + fillOpacity: 1, + opacity: 1 + }; + return style; } } -export class TbPolygonsDataLayer extends TbMapDataLayer { +export class TbPolygonsDataLayer extends TbShapesDataLayer { constructor(protected map: TbMap, inputSettings: PolygonsDataLayerSettings) { @@ -586,8 +789,8 @@ export class TbPolygonsDataLayer extends TbMapDataLayer { - return of(null); + protected doSetup(): Observable { + return super.doSetup(); } protected isValidLayerData(layerData: FormattedData): boolean { @@ -622,17 +825,28 @@ class TbCircleDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): L.Layer { const circleData = this.dataLayer.extractCircleCoordinates(data); const center = new L.LatLng(circleData.latitude, circleData.longitude); + const style = this.dataLayer.getShapeStyle(data, dsData); this.circle = L.circle(center, { radius: circleData.radius, - fillColor: '#3a77e7', - color: '#0742ad', - weight: 1, - fillOpacity: 0.4, - opacity: 1 + ...style }); + this.updateLabel(data, dsData); return this.circle; } + protected createEventListeners(data: FormattedData, dsData: FormattedData[]): void { + this.dataLayer.getMap().circleClick(this.circle, data.$datasource); + } + + protected unbindLabel() { + this.circle.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.circle.bindTooltip(content, { className: 'tb-polygon-label', permanent: true, direction: 'center'}) + .openTooltip(this.circle.getLatLng()); + } + public update(data: FormattedData, dsData: FormattedData[]): void { const circleData = this.dataLayer.extractCircleCoordinates(data); const center = new L.LatLng(circleData.latitude, circleData.longitude); @@ -642,10 +856,14 @@ class TbCircleDataLayerItem extends TbDataLayerItem { +export class TbCirclesDataLayer extends TbShapesDataLayer { constructor(protected map: TbMap, inputSettings: CirclesDataLayerSettings) { @@ -666,7 +884,7 @@ export class TbCirclesDataLayer extends TbMapDataLayer { - return of(null); + return super.doSetup(); } protected isValidLayerData(layerData: FormattedData): boolean { 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 f575576fc6..76264946e7 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 @@ -46,6 +46,7 @@ export enum MapType { export interface MapDataSourceSettings { dsType: DatasourceType; + dsLabel?: string; dsDeviceId?: string; dsEntityAliasId?: string; dsFilterId?: string; @@ -58,6 +59,7 @@ export interface TbMapDatasource extends Datasource { export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings): TbMapDatasource => { return { type: settings.dsType, + name: settings.dsLabel, deviceId: settings.dsDeviceId, entityAliasId: settings.dsEntityAliasId, filterId: settings.dsFilterId, @@ -66,11 +68,64 @@ export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSetting }; }; + +export enum DataLayerPatternType { + pattern = 'pattern', + function = 'function' +} + +export interface DataLayerPatternSettings { + show: boolean; + type: DataLayerPatternType; + pattern?: string; + patternFunction?: TbFunction; +} + +export enum DataLayerTooltipTrigger { + click = 'click', + hover = 'hover' +} + +export const dataLayerTooltipTriggers = Object.keys(DataLayerTooltipTrigger) as DataLayerTooltipTrigger[]; + +export const dataLayerTooltipTriggerTranslationMap = new Map( + [ + [DataLayerTooltipTrigger.click, 'widgets.maps.data-layer.tooltip-trigger-click'], + [DataLayerTooltipTrigger.hover, 'widgets.maps.data-layer.tooltip-trigger-hover'] + ] +); + +export interface DataLayerTooltipSettings extends DataLayerPatternSettings { + trigger: DataLayerTooltipTrigger; + autoclose: boolean; + offsetX: number; + offsetY: number; +} + export interface MapDataLayerSettings extends MapDataSourceSettings { additionalDataKeys?: DataKey[]; + label: DataLayerPatternSettings; + tooltip: DataLayerTooltipSettings; groups?: string[]; } +export const defaultBaseDataLayerSettings: Partial = { + label: { + show: true, + type: DataLayerPatternType.pattern, + pattern: '${entityName}' + }, + tooltip: { + show: true, + trigger: DataLayerTooltipTrigger.click, + autoclose: true, + type: DataLayerPatternType.pattern, + pattern: '${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details', + offsetX: 0, + offsetY: -1 + } +} + export type MapDataLayerType = 'markers' | 'polygons' | 'circles'; export const mapDataLayerValid = (dataLayer: MapDataLayerSettings, type: MapDataLayerType): boolean => { @@ -192,6 +247,7 @@ const defaultMarkerYPosFunction = 'var value = prevValue || 0.3;\n' + export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly = false): MarkersDataLayerSettings => mergeDeep({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First point' : '', xKey: { name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'latitude' : 'xPos'), label: MapType.geoMap === mapType ? 'latitude' : 'xPos', @@ -210,27 +266,34 @@ export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly } } as MarkersDataLayerSettings, defaultBaseMarkersDataLayerSettings as MarkersDataLayerSettings); -export const defaultBaseMarkersDataLayerSettings: Partial = { +export const defaultBaseMarkersDataLayerSettings: Partial = mergeDeep({ markerType: MarkerType.default, markerColor: { type: DataLayerColorType.constant, - color: '#FE7569', + color: '#307FE5', }, markerImage: { type: MarkerImageType.image, - image: createColorMarkerURI(tinycolor('#FE7569')), + image: createColorMarkerURI(tinycolor('#307FE5')), imageSize: 34 }, markerOffsetX: 0.5, markerOffsetY: 1 -}; +} as MarkersDataLayerSettings, defaultBaseDataLayerSettings); + +export interface ShapeDataLayerSettings extends MapDataLayerSettings { + fillColor: DataLayerColorSettings; + strokeColor: DataLayerColorSettings; + strokeWeight: number; +} -export interface PolygonsDataLayerSettings extends MapDataLayerSettings { +export interface PolygonsDataLayerSettings extends ShapeDataLayerSettings { polygonKey: DataKey; } export const defaultPolygonsDataLayerSettings = (functionsOnly = false): PolygonsDataLayerSettings => mergeDeep({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First polygon' : '', polygonKey: { name: functionsOnly ? 'f(x)' : 'perimeter', label: 'perimeter', @@ -240,16 +303,26 @@ export const defaultPolygonsDataLayerSettings = (functionsOnly = false): Polygon } } as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings as PolygonsDataLayerSettings); -export const defaultBasePolygonsDataLayerSettings: Partial = { - -} - -export interface CirclesDataLayerSettings extends MapDataLayerSettings { +export const defaultBasePolygonsDataLayerSettings: Partial = mergeDeep({ + fillColor: { + type: DataLayerColorType.constant, + color: 'rgba(51,136,255,0.2)', + }, + strokeColor: { + type: DataLayerColorType.constant, + color: '#3388ff', + }, + strokeWeight: 3 +} as Partial, defaultBaseDataLayerSettings, + {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

TimeStamp: ${ts:7}'}} as Partial) + +export interface CirclesDataLayerSettings extends ShapeDataLayerSettings { circleKey: DataKey; } export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesDataLayerSettings => mergeDeep({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First circle' : '', circleKey: { name: functionsOnly ? 'f(x)' : 'perimeter', label: 'perimeter', @@ -259,9 +332,18 @@ export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesD } } as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings as CirclesDataLayerSettings); -export const defaultBaseCirclesDataLayerSettings: Partial = { - -} +export const defaultBaseCirclesDataLayerSettings: Partial = mergeDeep({ + fillColor: { + type: DataLayerColorType.constant, + color: 'rgba(51,136,255,0.2)', + }, + strokeColor: { + type: DataLayerColorType.constant, + color: '#3388ff', + }, + strokeWeight: 3 +} as Partial, defaultBaseDataLayerSettings, + {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

TimeStamp: ${ts:7}'}} as Partial) export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => { switch (dataLayerType) { @@ -274,6 +356,17 @@ export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: Map } }; +export const defaultBaseMapDataLayerSettings = (dataLayerType: MapDataLayerType): T => { + switch (dataLayerType) { + case 'markers': + return defaultBaseMarkersDataLayerSettings as T; + case 'polygons': + return defaultBasePolygonsDataLayerSettings as T; + case 'circles': + return defaultBaseCirclesDataLayerSettings as T; + } +} + export interface AdditionalMapDataSourceSettings extends MapDataSourceSettings { dataKeys: DataKey[]; } @@ -617,6 +710,8 @@ export type MapSetting = GeoMapSettings & ImageMapSettings; export const defaultMapSettings: MapSetting = defaultGeoMapSettings; +export type MapActionHandler = ($event: Event, datasource: TbMapDatasource) => void; + export interface MarkerImageInfo { url: string; size: number; @@ -722,7 +817,7 @@ const mapDatasourceIsSame = (ds1: TbMapDatasource, ds2: TbMapDatasource): boolea if (ds1.type === ds2.type) { switch (ds1.type) { case DatasourceType.function: - return true; + return ds1.name === ds2.name; case DatasourceType.device: case DatasourceType.entity: if (ds1.filterId === ds2.filterId) { @@ -804,3 +899,39 @@ export const loadImageWithAspect = (imagePipe: ImagePipe, imageUrl: string): Obs return of(null); } }; + +const linkActionRegex = /([^<]*)<\/link-act>/g; +const buttonActionRegex = /([^<]*)<\/button-act>/g; + +const createTooltipLinkElement = (actionName: string, actionText: string): string => { + return `${actionText}`; +} + +const creatTooltipButtonElement = (actionName: string, actionText: string): string => { + return ``; +} + +export const processTooltipTemplate = (template: string): string => { + let actionTags: string; + let actionText: string; + let actionName: string; + let action: string; + + let match = linkActionRegex.exec(template); + while (match !== null) { + [actionTags, actionName, actionText] = match; + action = createTooltipLinkElement(actionName, actionText); + template = template.replace(actionTags, action); + match = linkActionRegex.exec(template); + } + + match = buttonActionRegex.exec(template); + while (match !== null) { + [actionTags, actionName, actionText] = match; + action = creatTooltipButtonElement(actionName, actionText); + template = template.replace(actionTags, action); + match = buttonActionRegex.exec(template); + } + + return template; +} 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 973d035683..a85406daa9 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 @@ -22,7 +22,7 @@ import { defaultImageMapSettings, GeoMapSettings, ImageMapSettings, - latLngPointToBounds, + latLngPointToBounds, MapActionHandler, MapSetting, MapType, MapZoomAction, @@ -34,7 +34,7 @@ import { import { WidgetContext } from '@home/models/widget-component.models'; import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; -import L, { LatLngBounds, LatLngTuple, PointExpression, Projection } from 'leaflet'; +import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, PointExpression, Projection } 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'; @@ -47,7 +47,7 @@ import { TbPolygonsDataLayer } from '@home/components/widget/lib/maps/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { widgetType } from '@shared/models/widget.models'; +import { Datasource, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; @@ -82,12 +82,23 @@ export abstract class TbMap { private readonly mapResize$: ResizeObserver; + private readonly tooltipActions: { [name: string]: MapActionHandler }; + private readonly markerClickActions: { [name: string]: MapActionHandler }; + private readonly polygonClickActions: { [name: string]: MapActionHandler }; + private readonly circleClickActions: { [name: string]: MapActionHandler }; + private tooltipInstances: ITooltipsterInstance[] = []; protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, protected containerElement: HTMLElement) { this.settings = mergeDeepIgnoreArray({} as S, this.defaultSettings(), this.inputSettings as S); + + this.tooltipActions = this.loadActions('tooltipAction'); + this.markerClickActions = this.loadActions('markerClick'); + this.polygonClickActions = this.loadActions('polygonClick'); + this.circleClickActions = this.loadActions('circleClick'); + $(containerElement).empty(); $(containerElement).addClass('tb-map-layout'); const mapElement = $('
'); @@ -331,12 +342,33 @@ export abstract class TbMap { if (this.settings.useDefaultCenterPosition) { bounds = bounds.extend(this.defaultCenterPosition); } - this.map.fitBounds(bounds, { padding: [10, 10], animate: false }); + this.map.fitBounds(bounds, { padding: [50, 50], animate: false }); this.map.invalidateSize(); } } } + private loadActions(name: string): { [name: string]: MapActionHandler } { + const descriptors = this.ctx.actionsApi.getActionDescriptors(name); + const actions: { [name: string]: MapActionHandler } = {}; + descriptors.forEach(descriptor => { + actions[descriptor.name] = ($event: Event, datasource: TbMapDatasource) => this.onCustomAction(descriptor, $event, datasource); + }); + return actions; + } + + private onCustomAction(descriptor: WidgetActionDescriptor, $event: Event, entityInfo: TbMapDatasource) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } + const { entityId, entityName, entityLabel, entityType } = entityInfo; + this.ctx.actionsApi.handleWidgetAction($event, descriptor, { + entityType, + id: entityId + }, entityName, null, entityLabel); + } + protected abstract defaultSettings(): S; protected abstract createMap(): Observable; @@ -370,6 +402,46 @@ export abstract class TbMap { return this.settings.mapType; } + public tooltipElementClick(element: HTMLElement, action: string, datasource: TbMapDatasource): void { + if (element && this.tooltipActions[action]) { + element.onclick = ($event) => + { + this.tooltipActions[action]($event, datasource); + return false; + }; + } + } + + public markerClick(marker: L.Layer, datasource: TbMapDatasource): void { + if (Object.keys(this.markerClickActions).length) { + marker.on('click', (event: LeafletMouseEvent) => { + for (const action in this.markerClickActions) { + this.markerClickActions[action](event.originalEvent, datasource); + } + }); + } + } + + public polygonClick(polygon: L.Layer, datasource: TbMapDatasource): void { + if (Object.keys(this.polygonClickActions).length) { + polygon.on('click', (event: LeafletMouseEvent) => { + for (const action in this.polygonClickActions) { + this.polygonClickActions[action](event.originalEvent, datasource); + } + }); + } + } + + public circleClick(circle: L.Layer, datasource: TbMapDatasource): void { + if (Object.keys(this.circleClickActions).length) { + circle.on('click', (event: LeafletMouseEvent) => { + for (const action in this.circleClickActions) { + this.circleClickActions[action](event.originalEvent, datasource); + } + }); + } + } + public destroy() { if (this.mapResize$) { this.mapResize$.disconnect(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html new file mode 100644 index 0000000000..892558b047 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html @@ -0,0 +1,86 @@ + + +
+ + + +
+ + {{ (patternType === 'label' ? 'widgets.maps.data-layer.label' : 'widgets.maps.data-layer.tooltip') | translate }} + + + {{ 'widgets.maps.data-layer.pattern-type-pattern' | translate }} + {{ 'widgets.maps.data-layer.pattern-type-function' | translate }} + +
+
+
+ + + + + + +
+
widgets.maps.data-layer.tooltip-trigger
+ + + + {{ dataLayerTooltipTriggerTranslationMap.get(trigger) | translate }} + + + +
+
+ + {{ 'widgets.maps.data-layer.auto-close-tooltips' | translate }} + +
+
+
widgets.maps.data-layer.tooltip-offset
+
+
widgets.maps.data-layer.tooltip-offset-horizontal
+ + + +
widgets.maps.data-layer.tooltip-offset-vertical
+ + + +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts new file mode 100644 index 0000000000..204e1db59a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -0,0 +1,180 @@ +/// +/// 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 } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { merge } from 'rxjs'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + DataLayerPatternSettings, + DataLayerPatternType, + DataLayerTooltipSettings, dataLayerTooltipTriggers, dataLayerTooltipTriggerTranslationMap +} from '@home/components/widget/lib/maps/map.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-data-layer-pattern-settings', + templateUrl: './data-layer-pattern-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataLayerPatternSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DataLayerPatternSettingsComponent), + multi: true + } + ] +}) +export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + DataLayerPatternType = DataLayerPatternType; + + dataLayerTooltipTriggers = dataLayerTooltipTriggers; + + dataLayerTooltipTriggerTranslationMap = dataLayerTooltipTriggerTranslationMap; + + settingsExpanded = false; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + @Input() + disabled: boolean; + + @Input() + patternType: 'label' | 'tooltip' = 'label'; + + @Input() + @coerceBoolean() + hasTooltipOffset = false; + + private modelValue: DataLayerPatternSettings | DataLayerTooltipSettings; + + private propagateChange = null; + + public patternSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + + this.patternSettingsFormGroup = this.fb.group({ + show: [null, []], + type: [null, []], + pattern: [null, [Validators.required]], + patternFunction: [null, [Validators.required]] + }); + if (this.patternType === 'tooltip') { + this.patternSettingsFormGroup.addControl('trigger', this.fb.control(null, [])); + this.patternSettingsFormGroup.addControl('autoclose', this.fb.control(null, [])); + if (this.hasTooltipOffset) { + this.patternSettingsFormGroup.addControl('offsetX', this.fb.control(null, [])); + this.patternSettingsFormGroup.addControl('offsetY', this.fb.control(null, [])); + } + } + this.patternSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + merge(this.patternSettingsFormGroup.get('show').valueChanges, + this.patternSettingsFormGroup.get('type').valueChanges + ).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.patternSettingsFormGroup.disable({emitEvent: false}); + } else { + this.patternSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: DataLayerPatternSettings | DataLayerTooltipSettings): void { + this.modelValue = value; + this.patternSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.settingsExpanded = this.patternSettingsFormGroup.get('show').value; + this.patternSettingsFormGroup.get('show').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((show) => { + this.settingsExpanded = show; + }); + } + + public validate(c: UntypedFormControl) { + const valid = this.patternSettingsFormGroup.valid; + return valid ? null : { + [this.patternType]: { + valid: false, + }, + }; + } + + private updateValidators() { + const show: boolean = this.patternSettingsFormGroup.get('show').value; + const type: DataLayerPatternType = this.patternSettingsFormGroup.get('type').value; + if (show) { + this.patternSettingsFormGroup.enable({emitEvent: false}); + if (type === DataLayerPatternType.pattern) { + this.patternSettingsFormGroup.get('pattern').enable({emitEvent: false}); + this.patternSettingsFormGroup.get('patternFunction').disable({emitEvent: false}); + } else { + this.patternSettingsFormGroup.get('pattern').disable({emitEvent: false}); + this.patternSettingsFormGroup.get('patternFunction').enable({emitEvent: false}); + } + } else { + this.patternSettingsFormGroup.disable({emitEvent: false}); + this.patternSettingsFormGroup.get('show').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.patternSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 711a004e4e..9ecaac60a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -34,6 +34,12 @@
+ + datasource.label + +
+ +
+
widgets.maps.data-layer.fill-color
+ +
+
+
widgets.maps.data-layer.stroke
+
+ + + px + + +
+
+
+ + + +
{{ 'widgets.maps.data-layer.groups' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index afcb164be8..6b15b49580 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -17,13 +17,13 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'; import { DialogComponent } from '@shared/components/dialog.component'; import { - CirclesDataLayerSettings, + CirclesDataLayerSettings, defaultBaseMapDataLayerSettings, MapDataLayerSettings, MapDataLayerType, MapType, MarkersDataLayerSettings, MarkerType, - PolygonsDataLayerSettings + PolygonsDataLayerSettings, ShapeDataLayerSettings } from '@home/components/widget/lib/maps/map.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -35,7 +35,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityType } from '@shared/models/entity-type.models'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; -import { genNextLabelForDataKeys } from '@core/utils'; +import { genNextLabelForDataKeys, mergeDeepIgnoreArray } from '@core/utils'; export interface MapDataLayerDialogData { settings: MapDataLayerSettings; @@ -92,12 +92,18 @@ export class MapDataLayerDialogComponent extends DialogComponent(this.dataLayerType), this.settings); + this.dataLayerFormGroup = this.fb.group({ dsType: [this.settings.dsType, [Validators.required]], + dsLabel: [this.settings.dsLabel, []], dsDeviceId: [this.settings.dsDeviceId, [Validators.required]], dsEntityAliasId: [this.settings.dsEntityAliasId, [Validators.required]], dsFilterId: [this.settings.dsFilterId, []], additionalDataKeys: [this.settings.additionalDataKeys, []], + label: [this.settings.label, []], + tooltip: [this.settings.tooltip, []], groups: [this.settings.groups, []] }); @@ -119,14 +125,20 @@ export class MapDataLayerDialogComponent extends DialogComponent + + + Link text')", + "tooltip-function": "Tooltip function", + "tooltip-trigger": "Tooltip trigger", + "tooltip-trigger-click": "Show tooltip on click", + "tooltip-trigger-hover": "Show tooltip on hover", + "auto-close-tooltips": "Auto-close tooltips", + "tooltip-offset": "Tooltip offset", + "tooltip-offset-horizontal": "Horizontal", + "tooltip-offset-vertical": "Vertical", "marker": { "latitude-key": "Latitude key", "longitude-key": "Longitude key", @@ -6898,10 +6915,10 @@ "marker-type-image": "Image", "image": "Image", "marker-image": "Marker image", - "marker-image-type-image": "Constant", + "marker-image-type-image": "Image", "marker-image-type-function": "Function", "custom-marker-image-size": "Custom marker image size", - "marker-image-function": "Function", + "marker-image-function": "Marker image function", "marker-images": "Marker images", "marker-offset": "Marker offset", "offset-horizontal": "Horizontal",