diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index 7f877dbb00..3f5368d95c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -45,7 +45,7 @@ class TbCircleDataLayerItem extends TbDataLayerItem this.editing = true); this.circle.on('pm:markerdragend', () => this.editing = false); - this.circle.on('pm:edit', (e) => this.saveCircleCoordinates()); + this.circle.on('pm:edit', () => this.saveCircleCoordinates()); this.circle.pm.enable(); } return []; @@ -142,6 +142,10 @@ class TbCircleDataLayerItem extends TbDataLayerItem { @@ -177,12 +179,13 @@ export abstract class TbDataLayerItem, dsData: FormattedData[]): void { @@ -365,7 +368,12 @@ export class DataLayerColorProcessor { } -export abstract class TbMapDataLayer, L extends L.Layer = L.Layer> implements L.TB.DataLayer { +export interface UnplacedMapDataItem { + entity: FormattedData; + dataLayer: TbMapDataLayer; +} + +export abstract class TbMapDataLayer = any, L extends L.Layer = L.Layer> implements L.TB.DataLayer { protected settings: S; @@ -386,13 +394,14 @@ export abstract class TbMapDataLayer[] = []; + private unplacedItems: UnplacedMapDataItem[] = []; public dataLayerLabelProcessor: DataLayerPatternProcessor; public dataLayerTooltipProcessor: DataLayerPatternProcessor; @@ -412,6 +421,7 @@ export abstract class TbMapDataLayer { @@ -559,6 +577,25 @@ export abstract class TbMapDataLayer { + this.polygon.on('pm:dragend', () => { this.savePolygonCoordinates(); this.editing = false; }); @@ -200,10 +200,14 @@ class TbPolygonDataLayerItem extends TbDataLayerItem this.editing = true); this.polygon.on('pm:markerdragend', () => this.editing = false); - this.polygon.on('pm:edit', (e) => this.savePolygonCoordinates()); + this.polygon.on('pm:edit', () => this.savePolygonCoordinates()); this.polygon.pm.enable(); const map = this.dataLayer.getMap(); map.getEditToolbar().getButton('remove')?.setDisabled(false); @@ -232,7 +236,7 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { + this.polygon.on('pm:rotateend', () => { this.savePolygonCoordinates(); }); this.polygon.pm.enableRotate(); rotateButton?.setActive(true); - this.polygon.on('pm:rotatedisable', (e) => { + this.polygon.on('pm:rotatedisable', () => { this.disablePolygonRotateMode(rotateButton); this.enablePolygonEditMode(); }); @@ -330,7 +334,7 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { constructor(options: TB.ToolbarButtonOptions) { super(options); this.id = options.id; + + const buttonText = this.options.showText ? this.options.title : null; this.button = $("") .attr('class', 'tb-control-button') .attr('href', '#') .attr('role', 'button') - .attr('title', this.options.title) - .html('
'); + .html('
' + (buttonText ? `
${buttonText}
` : '')); + if (this.options.showText) { + L.DomUtil.addClass(this.button[0], 'tb-control-text-button'); + } else { + this.button.attr('title', this.options.title); + } this.button.on('click', (e) => { e.stopPropagation(); @@ -391,7 +397,7 @@ class BottomToolbarControl extends L.Control { return this.toolbarButtons.find(b => b.getId() === id); } - open(buttons: TB.ToolbarButtonOptions[]): void { + open(buttons: TB.ToolbarButtonOptions[], showCloseButton = true): void { this.toolbarButtons.length = 0; @@ -401,19 +407,21 @@ class BottomToolbarControl extends L.Control { button.getButtonElement().appendTo(this.container); }); - const closeButton = $("
") - .attr('class', 'tb-control-button') - .attr('href', '#') - .attr('role', 'button') - .attr('title', this.options.closeTitle) - .html('
'); + if (showCloseButton) { + const closeButton = $("
") + .attr('class', 'tb-control-button') + .attr('href', '#') + .attr('role', 'button') + .attr('title', this.options.closeTitle) + .html('
'); - closeButton.on('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - this.close(); - }); - closeButton.appendTo(this.buttonContainer); + closeButton.on('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + this.close(); + }); + closeButton.appendTo(this.buttonContainer); + } } close(): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 4dcb17668d..da96bcc3e9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -16,6 +16,7 @@ @import '../../../../../../../scss/constants'; .tb-map-layout { + position: relative; display: flex; width: 100%; height: 100%; @@ -29,6 +30,7 @@ flex-direction: row; } .tb-map { + position: relative; flex: 1; &.leaflet-touch { .leaflet-bar { @@ -70,6 +72,73 @@ border-bottom-left-radius: 50%; border-bottom-right-radius: 50%; } + &.tb-control-button { + &.tb-control-text-button { + display: flex; + width: auto; + padding-right: 14px; + padding-left: 2px; + div.tb-control-text { + width: auto; + background: transparent; + font-family: Roboto; + font-size: 12px; + font-style: normal; + font-weight: 500; + } + } + &:not(.leaflet-disabled) { + &.active, &:hover { + &:before { + border-radius: 15px; + } + > div:not(.tb-control-text):not(.tb-close) { + background: $tb-primary-color; // primary color + } + } + > div { + background: rgba(0, 0, 0, 0.54); + } + } + > div { + width: 30px; + height: 30px; + line-height: 30px; + mask-repeat: no-repeat; + mask-position: center; + &.tb-layers { + mask-image: url('data:image/svg+xml,'); + } + &.tb-groups { + mask-image: url('data:image/svg+xml,'); + } + &.tb-remove { + mask-image: url('data:image/svg+xml,'); + } + &.tb-cut { + mask-image: url('data:image/svg+xml,'); + } + &.tb-rotate { + mask-image: url('data:image/svg+xml,'); + } + &.tb-place-marker { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-rectangle { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-polygon { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-circle { + mask-image: url('data:image/svg+xml,'); + } + &.tb-close { + background: #D12730; + mask-image: url('data:image/svg+xml,'); + } + } + } } } .tb-map-bottom-toolbar { @@ -84,54 +153,6 @@ } } } - .leaflet-control { - .tb-control-button { - &.active, &:hover { - > div { - background: $tb-primary-color; // primary color - } - } - > div { - width: 30px; - height: 30px; - background: rgba(0, 0, 0, 0.54); - line-height: 30px; - mask-repeat: no-repeat; - mask-position: center; - &.tb-layers { - mask-image: url('data:image/svg+xml,'); - } - &.tb-groups { - mask-image: url('data:image/svg+xml,'); - } - &.tb-remove { - mask-image: url('data:image/svg+xml,'); - } - &.tb-cut { - mask-image: url('data:image/svg+xml,'); - } - &.tb-rotate { - mask-image: url('data:image/svg+xml,'); - } - &.tb-place-marker { - mask-image: url('data:image/svg+xml,'); - } - &.tb-draw-rectangle { - mask-image: url('data:image/svg+xml,'); - } - &.tb-draw-polygon { - mask-image: url('data:image/svg+xml,'); - } - &.tb-draw-circle { - mask-image: url('data:image/svg+xml,'); - } - &.tb-close { - background: #D12730; - mask-image: url('data:image/svg+xml,'); - } - } - } - } .leaflet-map-pane:not(.leaflet-zoom-anim) { .leaflet-marker-icon { &.tb-hoverable:not(.tb-selected) { 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 346387ee6b..a6b91d9008 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 @@ -20,6 +20,7 @@ import { DataKeyValuePair, MapActionHandler, MapType, + mergeUnplacedDataItemsArrays, mergeMapDatasources, parseCenterPosition, TbCircleData, @@ -32,12 +33,12 @@ import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreAr import { DeepPartial } from '@shared/models/common'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { MapDataLayerType, TbDataLayerItem, - TbMapDataLayer, + TbMapDataLayer, UnplacedMapDataItem, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { FormattedData, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; @@ -51,6 +52,15 @@ import { AttributeData, AttributeScope, DataKeyType, LatestTelemetry } from '@sh import { EntityId } from '@shared/models/id/entity-id'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; +import { TbPopoverService } from '@shared/components/popover.service'; +import { + SelectMapEntityPanelComponent +} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { createColorMarkerShapeURI, MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; +import tinycolor from 'tinycolor2'; type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; @@ -61,6 +71,7 @@ export abstract class TbMap { protected defaultCenterPosition: [number, number]; protected ignoreUpdateBounds = false; + protected bounds: L.LatLngBounds; protected southWest = new L.LatLng(-L.Projection.SphericalMercator['MAX_LATITUDE'], -180); protected northEast = new L.LatLng(L.Projection.SphericalMercator['MAX_LATITUDE'], 180); @@ -94,6 +105,9 @@ export abstract class TbMap { private tooltipInstances: TooltipInstancesData[] = []; + private currentPopover: TbPopoverComponent; + private isAddingItem = false; + protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, protected containerElement: HTMLElement) { @@ -152,6 +166,9 @@ export abstract class TbMap { }); if (this.settings.useDefaultCenterPosition) { this.map.panTo(this.defaultCenterPosition); + this.bounds = this.map.getBounds(); + } else { + this.bounds = new L.LatLngBounds(null, null); } this.setupDataLayers(); this.setupEditMode(); @@ -265,6 +282,11 @@ export abstract class TbMap { this.deselectItem(); }); + if (this.dataLayers.some(dl => dl.isEditable())) { + this.map.pm.setGlobalOptions({ snappable: false } as L.PM.GlobalOptions); + this.map.pm.applyGlobalOptions(); + } + const addSupportedDataLayers = this.dataLayers.filter(dl => dl.isAddEnabled()); if (addSupportedDataLayers.length) { @@ -277,9 +299,25 @@ export abstract class TbMap { id: 'addMarker', title: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker'), iconClass: 'tb-place-marker', - click: (e, button) => {} + click: (e, button) => { + this.placeMarker(e, button); + } }); this.addMarkerButton.setDisabled(true); + createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), MarkerShape.markerShape1, tinycolor('rgba(255,255,255,0.75)')).subscribe( + ((iconUrl) => { + const icon = L.icon({ + iconUrl, + iconSize: [40, 40], + iconAnchor: [20, 40] + }); + this.map.pm.setGlobalOptions({ + markerStyle: { + icon + } + }); + }) + ); } this.addPolygonDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === MapDataLayerType.polygon); if (this.addPolygonDataLayers.length) { @@ -287,14 +325,18 @@ export abstract class TbMap { id: 'addRectangle', title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-rectangle'), iconClass: 'tb-draw-rectangle', - click: (e, button) => {} + click: (e, button) => { + this.drawRectangle(e, button); + } }); this.addRectangleButton.setDisabled(true); this.addPolygonButton = drawToolbar.toolbarButton({ id: 'addPolygon', title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-polygon'), iconClass: 'tb-draw-polygon', - click: (e, button) => {} + click: (e, button) => { + this.drawPolygon(e, button); + } }); this.addPolygonButton.setDisabled(true); } @@ -304,13 +346,138 @@ export abstract class TbMap { id: 'addCircle', title: this.ctx.translate.instant('widgets.maps.data-layer.circle.draw-circle'), iconClass: 'tb-draw-circle', - click: (e, button) => {} + click: (e, button) => { + this.drawCircle(e, button); + } }); this.addCircleButton.setDisabled(true); } } } + private placeMarker(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addMarkerDataLayers, + (entity) => { + this.map.pm.enableDraw('Marker'); + const hintText = this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint', {entityName: entity.entity.entityDisplayName}); + // @ts-ignore + this.map.pm.Draw.Marker._hintMarker.setTooltipContent(hintText); + }, + (entity, layer) => { + (entity.dataLayer as TbMarkersDataLayer).saveMarkerLocation(entity.entity, (layer as L.Marker).getLatLng()); + } + ); + } + + private drawRectangle(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addPolygonDataLayers, + (entity) => { + + }, + (entity, layer) => { + } + ); + } + + private drawPolygon(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addPolygonDataLayers, + (entity) => { + + }, + (entity, layer) => { + } + ); + } + + private drawCircle(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addCircleDataLayers, + (entity) => { + + }, + (entity, layer) => { + } + ); + } + + private placeItem(e: MouseEvent, button: L.TB.ToolbarButton, dataLayers: TbMapDataLayer[], + prepareDrawMode: (entity: UnplacedMapDataItem) => void, + saveLocation: (entity: UnplacedMapDataItem, layer: L.Layer) => void): void { + if (this.isAddingItem) { + return; + } + button.setActive(true); + this.deselectItem(false, true); + this.isAddingItem = true; + const items = mergeUnplacedDataItemsArrays(dataLayers.filter(dl => dl.isEnabled()).map(dl => dl.getUnplacedItems())).sort((entity1, entity2) => { + return entity1.entity.entityDisplayName.localeCompare(entity2.entity.entityDisplayName); + }); + this.selectEntityToPlace(e, items).subscribe((entity) => { + if (entity) { + + const finishAdd = () => { + this.map.off('pm:create'); + this.map.pm.disableDraw(); + this.dataLayers.forEach(dl => dl.enableEditMode()); + button.setActive(false); + this.isAddingItem = false; + this.editToolbar.close(); + }; + + this.map.once('pm:create', (e) => { + saveLocation(entity, e.layer); + // @ts-ignore + e.layer._pmTempLayer = true; + e.layer.remove(); + finishAdd(); + }); + + this.dataLayers.forEach(dl => dl.disableEditMode()); + + prepareDrawMode(entity); + + this.editToolbar.open([ + { + id: 'cancel', + iconClass: 'tb-close', + title: this.ctx.translate.instant('action.cancel'), + showText: true, + click: finishAdd + } + ], false); + } else { + button.setActive(false); + this.isAddingItem = false; + } + }); + } + + private selectEntityToPlace(e: MouseEvent, entities: UnplacedMapDataItem[]): Observable { + if (entities.length === 1) { + return of(entities[0]); + } else { + if (e) { + e.stopPropagation(); + } + const trigger = (e.target || e.srcElement || e.currentTarget) as Element; + const popoverService = this.ctx.$injector.get(TbPopoverService); + const ctx: any = { + entities + }; + const popoverPosition = ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'rightTop' : 'leftTop'; + const selectMapEntityPanelPopover = popoverService.displayPopover(trigger, this.ctx.renderer, + this.ctx.widgetContentContainer, SelectMapEntityPanelComponent, popoverPosition, true, null, + ctx, + {}, + {}, {}, false); + this.currentPopover = selectMapEntityPanelPopover; + return selectMapEntityPanelPopover.tbComponentRef.instance.entitySelected.asObservable().pipe( + tap(() => { + this.currentPopover = null; + }) + ); + } + } + private createdControlButtonTooltip(root: HTMLElement, side: TooltipPositioningSide) { import('tooltipster').then(() => { let tooltipData = this.tooltipInstances.find(d => d.root === root); @@ -382,6 +549,7 @@ export abstract class TbMap { private resize() { this.onResize(); this.map?.invalidateSize(); + this.currentPopover?.updatePosition(); } private updateBounds() { @@ -392,8 +560,9 @@ export abstract class TbMap { bounds = new L.LatLngBounds(null, null); dataLayersBounds.forEach(b => bounds.extend(b)); const mapBounds = this.map.getBounds(); - if (bounds.isValid() && this.settings.fitMapBounds && !mapBounds.contains(bounds)) { - if (!this.ignoreUpdateBounds) { + if (bounds.isValid() && (!this.bounds || !this.bounds.isValid() || !this.bounds.equals(bounds) && this.settings.fitMapBounds && !mapBounds.contains(bounds))) { + this.bounds = bounds; + if (!this.ignoreUpdateBounds && !this.isAddingItem) { this.fitBounds(bounds); } } @@ -424,16 +593,16 @@ export abstract class TbMap { private updateAddButtonsStates() { if (this.addMarkerButton) { - this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.hasUnplacedItems())); + this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } if (this.addRectangleButton) { - this.addRectangleButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.hasUnplacedItems())); + this.addRectangleButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } if (this.addPolygonButton) { - this.addPolygonButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.hasUnplacedItems())); + this.addPolygonButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } if (this.addCircleButton) { - this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.hasUnplacedItems())); + this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } } @@ -480,6 +649,10 @@ export abstract class TbMap { return this.settings.mapType; } + public enabledDataLayersUpdated() { + this.updateAddButtonsStates(); + } + public tooltipElementClick(element: HTMLElement, action: string, datasource: TbMapDatasource): void { if (element && this.tooltipActions[action]) { element.onclick = ($event) => @@ -527,6 +700,9 @@ export abstract class TbMap { } public selectItem(item: TbDataLayerItem, cancel = false, force = false): boolean { + if (this.isAddingItem) { + return false; + } let deselected = true; if (this.selectedDataItem) { deselected = this.selectedDataItem.deselect(cancel, force); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 1f43e83c85..aa343e6520 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -25,6 +25,7 @@ import { Observable, Observer, of, switchMap } from 'rxjs'; import { map } from 'rxjs/operators'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; +import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; export enum MapType { geoMap = 'geoMap', @@ -1019,6 +1020,16 @@ const mergeMapDatasource = (target: TbMapDatasource, source: TbMapDatasource): T return target; } +export const mergeUnplacedDataItemsArrays = (dataItemsArrays: UnplacedMapDataItem[][]): UnplacedMapDataItem[] => { + const itemsMap = new Map(); + dataItemsArrays.forEach(dataItems => { + dataItems.forEach(dataItem => { + itemsMap.set(dataItem.entity.$datasource.entityId, dataItem); + }); + }); + return Array.from(itemsMap.values()); +} + const imageAspectMap: {[key: string]: ImageWithAspect} = {}; const imageLoader = (imageUrl: string): Observable => new Observable((observer: Observer) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html new file mode 100644 index 0000000000..afa4f23d97 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html @@ -0,0 +1,44 @@ + +
+ + {{ (selectEntityFormGroup.get('entity').value ? 'entity.entity' : 'widgets.maps.data-layer.select-entity') | translate }} + + + {{ entity.entity.entityDisplayName }} + + + widgets.maps.data-layer.select-entity-hint + +
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.scss new file mode 100644 index 0000000000..63ff621ecd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.scss @@ -0,0 +1,39 @@ +/** + * 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-select-map-entity-panel { + width: 320px; + max-width: 90vw; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + width: 90vw; + } + .mat-mdc-form-field-hint-wrapper { + padding: 0; + } + .tb-select-map-entity-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts new file mode 100644 index 0000000000..7b0ec943a0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts @@ -0,0 +1,69 @@ +/// +/// 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, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +@Component({ + selector: 'tb-select-map-entity-panel', + templateUrl: './select-map-entity-panel.component.html', + providers: [], + styleUrls: ['./select-map-entity-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SelectMapEntityPanelComponent extends PageComponent implements OnInit { + + @Input() + entities: UnplacedMapDataItem[]; + + @Output() + entitySelected = new EventEmitter(); + + selectEntityFormGroup: UntypedFormGroup; + + selectedEntity: UnplacedMapDataItem = null; + + constructor(private fb: UntypedFormBuilder, + protected store: Store, + private popover: TbPopoverComponent) { + super(store); + } + + ngOnInit(): void { + this.selectEntityFormGroup = this.fb.group( + { + entity: ['', Validators.required] + } + ); + this.popover.tbDestroy.subscribe(() => { + this.entitySelected.emit(this.selectedEntity); + }); + } + + cancel() { + this.popover.hide(); + } + + selectEntity() { + this.selectedEntity = this.selectEntityFormGroup.value.entity; + this.popover.hide(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 1530561924..acd07ad0da 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -89,6 +89,9 @@ import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/scada-symbol-widget.component'; import { TwoSegmentButtonWidgetComponent } from '@home/components/widget/lib/button/two-segment-button-widget.component'; import { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget.component'; +import { + SelectMapEntityPanelComponent +} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; @NgModule({ declarations: [ @@ -143,6 +146,7 @@ import { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget. UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, ScadaSymbolWidgetComponent, + SelectMapEntityPanelComponent, MapWidgetComponent ], imports: [ diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index eaf6b6105e..5dc88a0bce 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -248,6 +248,8 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, this.widgetContext.isPreview = this.isPreview; this.widgetContext.isMobile = this.isMobile; this.widgetContext.toastTargetId = this.toastTargetId; + this.widgetContext.renderer = this.renderer; + this.widgetContext.widgetContentContainer = this.widgetContentContainer; this.widgetContext.subscriptionApi = { createSubscription: this.createSubscription.bind(this), diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index c599fbf16d..5380f1ea84 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -46,7 +46,15 @@ import { WidgetActionsApi, WidgetSubscriptionApi } from '@core/api/widget-api.models'; -import { ChangeDetectorRef, InjectionToken, Injector, NgZone, TemplateRef, Type } from '@angular/core'; +import { + ChangeDetectorRef, + InjectionToken, + Injector, + NgZone, Renderer2, + TemplateRef, + Type, + ViewContainerRef +} from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { RafService } from '@core/services/raf.service'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; @@ -215,6 +223,8 @@ export class WidgetContext { http: HttpClient; sanitizer: DomSanitizer; router: Router; + renderer: Renderer2; + widgetContentContainer: ViewContainerRef; private changeDetectorValue: ChangeDetectorRef; private containerChangeDetectorValue: ChangeDetectorRef; 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 260999408a..9fa662c127 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7718,7 +7718,8 @@ }, "edit": "Edit marker", "remove-marker-for": "Remove marker for '{{entityName}}'", - "place-marker": "Place marker" + "place-marker": "Place marker", + "place-marker-hint": "Click to place '{{entityName}}' entity" }, "polygon": { "polygon-key": "Polygon key", @@ -7747,7 +7748,9 @@ "edit": "Edit circle", "remove-circle-for": "Remove circle for '{{entityName}}'", "draw-circle": "Draw circle" - } + }, + "select-entity": "Select entity", + "select-entity-hint": "Hint: after selection click at the map to set position" }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position", diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index b75743c139..e24c632a3b 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -94,6 +94,7 @@ declare module 'leaflet' { title: string; click: (e: MouseEvent, button: ToolbarButton) => void; iconClass: string; + showText?: boolean; } class ToolbarButton extends Control{ @@ -118,7 +119,7 @@ declare module 'leaflet' { class BottomToolbarControl extends Control { constructor(options: BottomToolbarControlOptions); getButton(id: string): ToolbarButton | undefined; - open(buttons: ToolbarButtonOptions[]): void; + open(buttons: ToolbarButtonOptions[], showCloseButton?: boolean): void; close(): void; container: HTMLElement; }