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 caccea87a7..c0dcb8e980 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,6 +45,7 @@ class TbCircleDataLayerItem extends TbDataLayerItem, _dsData: FormattedData[]): void { - this.dataLayer.getMap().circleClick(this.circle, data.$datasource); + this.dataLayer.getMap().circleClick(this, data.$datasource); } protected unbindLabel() { @@ -62,7 +63,7 @@ class TbCircleDataLayerItem extends TbDataLayerItem, center: L.LatLng, radius: number): void { - const converted = this.map.coordinatesToCircleData(center, radius); + const converted = center ? this.map.coordinatesToCircleData(center, radius) : null; const circleData = [ { dataKey: this.settings.circleKey, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index f08fd0bca8..9597464e9e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -39,17 +39,19 @@ import { parseTbFunction, safeExecuteTbFunction } from '@core/utils'; -import L, { LatLngBounds } from 'leaflet'; +import L from 'leaflet'; import { CompiledTbFunction } from '@shared/models/js-function.models'; import { map } from 'rxjs/operators'; import { WidgetContext } from '@home/models/widget-component.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; -export abstract class TbDataLayerItem, L extends L.Layer = L.Layer> { +export abstract class TbDataLayerItem = TbMapDataLayer, L extends L.Layer = L.Layer> { protected layer: L; protected tooltip: L.Popup; protected data: FormattedData; + protected selected = false; protected constructor(data: FormattedData, dsData: FormattedData[], @@ -61,6 +63,7 @@ export abstract class TbDataLayerItem { + if (!this.isEditing()) { + this.dataLayer.getMap().selectItem(this); + } + }); + this.layer.on('remove', () => { + if (this.selected) { + this.dataLayer.getMap().deselectItem(); + } + }); + } + } + protected enableEdit(): void { if (this.dataLayer.isHoverable()) { this.addItemClass('tb-hoverable'); @@ -110,16 +128,57 @@ export abstract class TbDataLayerItem { + this.removeDataItem(); + }, + iconClass: 'tb-remove' + }); + } + return buttons; + } else { + return []; + } + } + + public deselect() { + if (this.selected) { + this.selected = false; + this.layer.closePopup(); + this.updateSelectedState(); + } + } + + public isSelected() { + return this.selected; + } + public editModeUpdated() { if (this.dataLayer.isEditMode()) { this.enableEdit(); } else { this.disableEdit(); } + this.updateSelectedState(); } public update(data: FormattedData, dsData: FormattedData[]): void { @@ -128,14 +187,25 @@ export abstract class TbDataLayerItem, dsData: FormattedData[]) { if (this.settings.tooltip.show) { let tooltipTemplate = this.dataLayer.dataLayerTooltipProcessor.processPattern(data, dsData); @@ -157,13 +227,25 @@ export abstract class TbDataLayerItem { + if (this.tooltip.isOpen()) { + this.layer.closePopup(); + } else if (!this.isEditing()) { + this.layer.openPopup(); + } + }); + } else if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.hover) { this.layer.on('mouseover', () => { - this.layer.openPopup(); + if (!this.isEditing()) { + this.layer.openPopup(); + } }); this.layer.on('mousemove', (e) => { this.tooltip.setLatLng(e.latlng); @@ -345,7 +427,7 @@ export abstract class TbMapDataLayer, _dsData: FormattedData[]): void { - this.dataLayer.getMap().markerClick(this.marker, data.$datasource); + this.dataLayer.getMap().markerClick(this, data.$datasource); } protected unbindLabel() { @@ -122,7 +128,7 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]) { this.dataLayer.markerIconProcessor.createMarkerIcon(data, dsData).subscribe( (iconInfo) => { - iconInfo.icon.options.className = this.updateIconClasses(iconInfo.icon.options.className); - this.marker.setIcon(iconInfo.icon); - const anchor = iconInfo.icon.options.iconAnchor; + let icon: L.Icon | L.DivIcon; + const options = deepClone(iconInfo.icon.options); + options.className = this.updateIconClasses(options.className); + if (iconInfo.icon instanceof L.Icon) { + icon = L.icon(options as L.IconOptions); + } else { + icon = L.divIcon(options); + } + this.marker.setIcon(icon); + const anchor = options.iconAnchor; if (anchor && Array.isArray(anchor)) { this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; } else { @@ -195,11 +216,17 @@ class TbMarkerDataLayerItem extends TbDataLayerItem { if (!classes.includes(clazz)) { classes.push(clazz); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index b16952a317..58d51782fa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -48,7 +48,8 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, _dsData: FormattedData[]): void { - this.dataLayer.getMap().polygonClick(this.polygonContainer, data.$datasource); + this.dataLayer.getMap().polygonClick(this, data.$datasource); } protected unbindLabel() { @@ -103,7 +104,12 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { this.editing = true; }); - this.polygon.on('pm:dragend', () => { + this.polygon.on('pm:drag', () => { + if (this.tooltip?.isOpen()) { + this.tooltip.setLatLng(this.polygon.getBounds().getCenter()); + } + }); + this.polygon.on('pm:dragend', (e) => { this.savePolygonCoordinates(); this.editing = false; }); @@ -115,6 +121,14 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, coordinates: TbPolygonCoordinates): void { - const converted = this.map.coordinatesToPolygonData(coordinates); + const converted = coordinates ? this.map.coordinatesToPolygonData(coordinates) : null; const polygonData = [ { dataKey: this.settings.polygonKey, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 0aafc0889c..f316975bf3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import L, { Coords, TB, TileLayerOptions } from 'leaflet'; +import L, { TB } from 'leaflet'; import { guid } from '@core/utils'; import 'leaflet-providers'; import '@geoman-io/leaflet-geoman-free'; @@ -276,6 +276,81 @@ class GroupsControl extends SidebarPaneControl { } } +class ToolbarButton extends L.Control { + private readonly button: JQuery; + constructor(options: TB.ToolbarButtonOptions) { + super(options); + + this.button = $("") + .attr('class', 'tb-control-button') + .attr('href', '#') + .attr('role', 'button') + .attr('title', this.options.title) + .html('
'); + + this.button.on('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + this.options.click(e.originalEvent, this); + }); + } + + addToToolbar(toolbar: BottomToolbarControl): void { + this.button.appendTo(toolbar.container); + } +} + +class BottomToolbarControl extends L.Control { + + private readonly buttonContainer: JQuery; + + container: HTMLElement; + + constructor(options: TB.BottomToolbarControlOptions) { + super(options); + const controlContainer = $('.leaflet-control-container', options.mapElement); + const toolbar = $('
'); + toolbar.appendTo(controlContainer); + this.buttonContainer = $('
'); + this.buttonContainer.appendTo(toolbar); + this.container = this.buttonContainer[0]; + } + + addTo(map: L.Map): this { + return this; + } + + open(buttons: TB.ToolbarButtonOptions[]): void { + + buttons.forEach(buttonOption => { + const button = new ToolbarButton(buttonOption); + button.addToToolbar(this); + }); + + 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); + } + + close(): void { + this.buttonContainer.empty(); + if (this.options.onClose) { + this.options.onClose(); + } + } + +} + const sidebar = (options: TB.SidebarControlOptions): SidebarControl => { return new SidebarControl(options); } @@ -292,6 +367,10 @@ const groups = (options: TB.GroupsControlOptions): GroupsControl => { return new GroupsControl(options); } +const bottomToolbar = (options: TB.BottomToolbarControlOptions): BottomToolbarControl => { + return new BottomToolbarControl(options); +} + class ChinaProvider extends L.TileLayer { static chinaProviders: L.TB.TileLayer.ChinaProvidersData = { @@ -303,7 +382,7 @@ class ChinaProvider extends L.TileLayer { } }; - constructor(type: string, options?: TileLayerOptions) { + constructor(type: string, options?: L.TileLayerOptions) { options = options || {}; const parts = type.split('.'); @@ -316,7 +395,7 @@ class ChinaProvider extends L.TileLayer { super(url, options); } - getTileUrl(coords: Coords): string { + getTileUrl(coords: L.Coords): string { const data = { s: this._getSubdomain(coords), x: coords.x, @@ -338,7 +417,7 @@ class ChinaProvider extends L.TileLayer { } } -const chinaProvider = (type: string, options?: TileLayerOptions): ChinaProvider => { +const chinaProvider = (type: string, options?: L.TileLayerOptions): ChinaProvider => { return new ChinaProvider(type, options); } @@ -347,10 +426,13 @@ L.TB = L.TB || { SidebarPaneControl, LayersControl, GroupsControl, + ToolbarButton, + BottomToolbarControl, sidebar, sidebarPane, layers, groups, + bottomToolbar, TileLayer: { ChinaProvider }, 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 238d91f03f..fadb85b201 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 @@ -17,6 +17,7 @@ //$map-element-hover-color: #307FE5; $map-element-hover-color: rgba(0,0,0,0.56); +$map-element-selected-color: #307FE5; .tb-map-layout { display: flex; @@ -79,6 +80,17 @@ $map-element-hover-color: rgba(0,0,0,0.56); } } } + .tb-map-bottom-toolbar { + left: 0; + right: 0; + display: flex; + flex-direction: row; + justify-content: center; + .leaflet-bar { + display: flex; + flex-direction: row; + } + } } .leaflet-control { .tb-control-button { @@ -100,6 +112,27 @@ $map-element-hover-color: rgba(0,0,0,0.56); &.tb-groups { mask-image: url('data:image/svg+xml,'); } + &.tb-remove { + 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) { + svg { + transition: filter 0.2s; + } + } + } + img.leaflet-marker-icon, path { + &.tb-hoverable:not(.tb-selected) { + transition: filter 0.2s; } } } @@ -110,28 +143,35 @@ $map-element-hover-color: rgba(0,0,0,0.56); &.tb-draggable { cursor: move; } - &.tb-hoverable { - svg { - transition: filter 0.2s; - } + &.tb-hoverable:not(.tb-selected) { &:hover { svg { - filter: drop-shadow( 0 0 4px $map-element-hover-color); + //filter: drop-shadow( 0 0 4px $map-element-hover-color); + filter: brightness(0.8) drop-shadow( 0 0 4px $map-element-hover-color); } } } + &.tb-selected { + svg { + filter: brightness(0.8); + //animation: tb-selected-animation 0.5s linear 0s infinite alternate; + } + } } } img.leaflet-marker-icon, path { &.tb-draggable { cursor: move; } - &.tb-hoverable { - transition: filter 0.2s; + &.tb-hoverable:not(.tb-selected) { &:hover { - filter: drop-shadow( 0 0 4px $map-element-hover-color); + filter: brightness(0.8) drop-shadow( 0 0 4px $map-element-hover-color); } } + &.tb-selected { + filter: brightness(0.8); + //animation: tb-selected-animation 0.5s linear 0s infinite alternate; + } } .tb-cluster-marker-container { border: none; @@ -144,6 +184,11 @@ $map-element-hover-color: rgba(0,0,0,0.56); width: 40px; height: 40px; } + .tb-marker-label, .tb-polygon-label, .tb-circle-label { + border: none; + background: none; + box-shadow: none; + } } .tb-map-sidebar { .tb-layers, .tb-groups { @@ -267,3 +312,14 @@ $map-element-hover-color: rgba(0,0,0,0.56); } } } + +@keyframes tb-selected-animation { + 0% { + //filter: drop-shadow( 0 0 2px $map-element-selected-color); + filter: brightness(1); + } + 100% { + //filter: drop-shadow( 0 0 4px $map-element-selected-color) drop-shadow( 0 0 4px $map-element-selected-color); + filter: brightness(0.8); + } +} 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 672682591c..dc36f64bd2 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 @@ -32,7 +32,11 @@ import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; -import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { + MapDataLayerType, + TbDataLayerItem, + TbMapDataLayer, +} 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'; import { EntityDataPageLink } from '@shared/models/query/query.models'; @@ -44,6 +48,9 @@ import { AttributeService } from '@core/http/attribute.service'; import { AttributeData, AttributeScope, DataKeyType, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; + +type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; export abstract class TbMap { @@ -59,10 +66,14 @@ export abstract class TbMap { protected dataLayers: TbMapDataLayer[]; protected dsData: FormattedData[]; + protected selectedDataItem: TbDataLayerItem; + protected mapElement: HTMLElement; protected sidebar: L.TB.SidebarControl; + protected editToolbar: L.TB.BottomToolbarControl; + private readonly mapResize$: ResizeObserver; private readonly tooltipActions: { [name: string]: MapActionHandler }; @@ -70,7 +81,7 @@ export abstract class TbMap { private readonly polygonClickActions: { [name: string]: MapActionHandler }; private readonly circleClickActions: { [name: string]: MapActionHandler }; - private tooltipInstances: ITooltipsterInstance[] = []; + private tooltipInstances: TooltipInstancesData[] = []; protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, @@ -133,7 +144,7 @@ export abstract class TbMap { } this.setupDataLayers(); this.setupEditMode(); - this.createdControlButtonTooltip(); + this.createdControlButtonTooltip(this.mapElement, ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left'); } private setupDataLayers() { @@ -231,21 +242,36 @@ export abstract class TbMap { } private setupEditMode() { - const dragEnabled = this.dataLayers.some(dl => dl.isDragEnabled()); - if (dragEnabled) { - //this.map.pm.enableGlobalDragMode(); - } + this.editToolbar = L.TB.bottomToolbar({ + mapElement: $(this.mapElement), + closeTitle: this.ctx.translate.instant('action.cancel'), + onClose: () => { + this.deselectItem(); + } + }).addTo(this.map); + + this.map.on('click', () => { + this.deselectItem(); + }); } - private createdControlButtonTooltip() { + private createdControlButtonTooltip(root: HTMLElement, side: TooltipPositioningSide) { import('tooltipster').then(() => { + let tooltipData = this.tooltipInstances.find(d => d.root === root); + if (!tooltipData) { + tooltipData = { + root, + instances: [] + } + this.tooltipInstances.push(tooltipData); + } if ($.tooltipster) { - this.tooltipInstances.forEach((instance) => { + tooltipData.instances.forEach((instance) => { instance.destroy(); }); - this.tooltipInstances = []; + tooltipData.instances = []; } - $(this.mapElement) + $(root) .find('a[role="button"]:not(.leaflet-pm-action)') .each((_index, element) => { let title: string; @@ -267,7 +293,7 @@ export abstract class TbMap { scroll: true, mouseleave: true }, - side: ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left', + side, distance: 2, trackOrigin: true, functionBefore: (_instance, helper) => { @@ -277,7 +303,14 @@ export abstract class TbMap { }, } ); - this.tooltipInstances.push(tooltip.tooltipster('instance')); + const instance = tooltip.tooltipster('instance'); + tooltipData.instances.push(instance); + instance.on('destroyed', () => { + const index = tooltipData.instances.indexOf(instance); + if (index > -1) { + tooltipData.instances.splice(index, 1); + } + }); }); }); } @@ -385,36 +418,64 @@ export abstract class TbMap { } } - public markerClick(marker: L.Layer, datasource: TbMapDatasource): void { + public markerClick(marker: TbDataLayerItem, datasource: TbMapDatasource): void { if (Object.keys(this.markerClickActions).length) { - marker.on('click', (event: L.LeafletMouseEvent) => { - for (const action in this.markerClickActions) { - this.markerClickActions[action](event.originalEvent, datasource); + marker.getLayer().on('click', (event: L.LeafletMouseEvent) => { + if (!marker.isEditing()) { + for (const action in this.markerClickActions) { + this.markerClickActions[action](event.originalEvent, datasource); + } } }); } } - public polygonClick(polygon: L.Layer, datasource: TbMapDatasource): void { + public polygonClick(polygon: TbDataLayerItem, datasource: TbMapDatasource): void { if (Object.keys(this.polygonClickActions).length) { - polygon.on('click', (event: L.LeafletMouseEvent) => { - for (const action in this.polygonClickActions) { - this.polygonClickActions[action](event.originalEvent, datasource); + polygon.getLayer().on('click', (event: L.LeafletMouseEvent) => { + if (!polygon.isEditing()) { + for (const action in this.polygonClickActions) { + this.polygonClickActions[action](event.originalEvent, datasource); + } } }); } } - public circleClick(circle: L.Layer, datasource: TbMapDatasource): void { + public circleClick(circle: TbDataLayerItem, datasource: TbMapDatasource): void { if (Object.keys(this.circleClickActions).length) { - circle.on('click', (event: L.LeafletMouseEvent) => { - for (const action in this.circleClickActions) { - this.circleClickActions[action](event.originalEvent, datasource); + circle.getLayer().on('click', (event: L.LeafletMouseEvent) => { + if (!circle.isEditing()) { + for (const action in this.circleClickActions) { + this.circleClickActions[action](event.originalEvent, datasource); + } } }); } } + public selectItem(item: TbDataLayerItem): void { + if (this.selectedDataItem) { + this.selectedDataItem.deselect(); + this.selectedDataItem = null; + this.editToolbar.close(); + } + this.selectedDataItem = item; + if (this.selectedDataItem) { + const buttons = this.selectedDataItem.select(); + this.editToolbar.open(buttons); + this.createdControlButtonTooltip(this.editToolbar.container, 'top'); + } + } + + public deselectItem(): void { + this.selectItem(null); + } + + public getSelectedDataItem(): TbDataLayerItem { + return this.selectedDataItem; + } + public saveItemData(datasource: TbMapDatasource, data: DataKeyValuePair[]): Observable { const attributeService = this.ctx.$injector.get(AttributeService); const attributes: AttributeData[] = []; @@ -466,8 +527,10 @@ export abstract class TbMap { if (this.map) { this.map.remove(); } - this.tooltipInstances.forEach((instance) => { - instance.destroy(); + this.tooltipInstances.forEach((data) => { + data.instances.forEach(instance => { + instance.destroy(); + }) }); } diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index b5d40d3264..ab108c5c73 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -15,7 +15,7 @@ /// import { FormattedData } from '@shared/models/widget.models'; -import L from 'leaflet'; +import L, { Control, ControlOptions } from 'leaflet'; import { TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; // redeclare module, maintains compatibility with @types/leaflet @@ -89,6 +89,30 @@ declare module 'leaflet' { constructor(options: GroupsControlOptions); } + interface ToolbarButtonOptions extends ControlOptions{ + title: string; + click: (e: MouseEvent, button: ToolbarButton) => void; + iconClass: string; + } + + class ToolbarButton extends Control{ + constructor(options: ToolbarButtonOptions); + addToToolbar(toolbar: BottomToolbarControl): void; + } + + interface BottomToolbarControlOptions extends ControlOptions { + mapElement: JQuery; + closeTitle: string; + onClose: () => void; + } + + class BottomToolbarControl extends Control { + constructor(options: BottomToolbarControlOptions); + open(buttons: ToolbarButtonOptions[]): void; + close(): void; + container: HTMLElement; + } + function sidebar(options: SidebarControlOptions): SidebarControl; function sidebarPane(options: O): SidebarPaneControl; @@ -97,6 +121,8 @@ declare module 'leaflet' { function groups(options: GroupsControlOptions): GroupsControl; + function bottomToolbar(options: BottomToolbarControlOptions): BottomToolbarControl; + namespace TileLayer { interface ChinaProvidersData {