diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts index 2a390a39fc..9c522abf94 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts @@ -16,9 +16,7 @@ import L, { FeatureGroup, LatLngBounds, LatLngTuple, PointExpression, Projection } from 'leaflet'; import tinycolor from 'tinycolor2'; -import 'leaflet-providers'; -import 'leaflet.markercluster'; -import '@geoman-io/leaflet-geoman-free'; +import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { CircleData, @@ -125,10 +123,6 @@ export default abstract class LeafletMap { private initMarkerClusterSettings() { const markerClusteringSettings: WidgetMarkerClusteringSettings = this.options; if (markerClusteringSettings.useClusterMarkers) { - // disabled marker cluster icon - (L as any).MarkerCluster = (L as any).MarkerCluster.extend({ - options: { pmIgnore: true, ...L.Icon.prototype.options } - }); this.clusteringSettings = { spiderfyOnMaxZoom: markerClusteringSettings.spiderfyOnMaxZoom, zoomToBoundsOnClick: markerClusteringSettings.zoomOnClick, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts index d62b2b9217..c642768a4f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts @@ -52,7 +52,7 @@ export class Marker { this.leafletMarker = L.marker(this.location, { pmIgnore: !settings.draggableMarker, snapIgnore: !snappable, - tbMarkerData: this.data + tbMarkerData: this.data as any }); this.markerOffset = [ @@ -101,7 +101,7 @@ export class Marker { setDataSources(data: FormattedData, dataSources: FormattedData[]) { this.data = data; this.dataSources = dataSources; - this.leafletMarker.options.tbMarkerData = data; + this.leafletMarker.options.tbMarkerData = data as any; } updateMarkerTooltip(data: FormattedData) { 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 new file mode 100644 index 0000000000..d53d30e6e5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -0,0 +1,120 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + CirclesDataLayerSettings, + defaultBaseCirclesDataLayerSettings, isJSON, TbCircleData, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import L from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { Observable } from 'rxjs'; +import { isNotEmptyStr } from '@core/utils'; +import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +class TbCircleDataLayerItem extends TbDataLayerItem { + + private circle: L.Circle; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: CirclesDataLayerSettings, + protected dataLayer: TbCirclesDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, 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, + ...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); + if (!this.circle.getLatLng().equals(center)) { + this.circle.setLatLng(center); + } + if (this.circle.getRadius() !== circleData.radius) { + this.circle.setRadius(circleData.radius); + } + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + const style = this.dataLayer.getShapeStyle(data, dsData); + this.circle.setStyle(style); + } +} + +export class TbCirclesDataLayer extends TbShapesDataLayer { + + constructor(protected map: TbMap, + inputSettings: CirclesDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.circle; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.circleKey); + return datasource; + } + + protected defaultBaseSettings(): Partial { + return defaultBaseCirclesDataLayerSettings; + } + + protected doSetup(): Observable { + return super.doSetup(); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem { + throw new TbCircleDataLayerItem(data, dsData, this.settings, this); + } + + public extractCircleCoordinates(data: FormattedData) { + const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); + return this.map.convertCircleData(circleData); + } + + +} 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 new file mode 100644 index 0000000000..c14937ef00 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -0,0 +1,361 @@ +/// +/// 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 { + DataLayerColorSettings, + DataLayerColorType, + DataLayerPatternSettings, + DataLayerPatternType, + DataLayerTooltipTrigger, + MapDataLayerSettings, + mapDataSourceSettingsToDatasource, + MapStringFunction, + MapType, + processTooltipTemplate, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { FormattedData } from '@shared/models/widget.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { + createLabelFromPattern, + guid, + isDefined, + mergeDeepIgnoreArray, + parseTbFunction, + safeExecuteTbFunction +} from '@core/utils'; +import L, { LatLngBounds } 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> { + + protected layer: L; + protected tooltip: L.Popup; + + protected constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: S, + protected dataLayer: D) { + this.layer = this.create(data, dsData); + if (this.settings.tooltip?.show) { + this.createTooltip(data.$datasource); + this.updateTooltip(data, dsData); + } + this.createEventListeners(data, dsData); + try { + this.dataLayer.getDataLayerContainer().addLayer(this.layer); + } catch (e) { + console.warn(e); + } + } + + protected abstract create(data: FormattedData, dsData: FormattedData[]): L; + + 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.getDataLayerContainer().removeLayer(this.layer); + } + + public getLayer(): L { + return 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); + }); + } + +} + +export enum MapDataLayerType { + marker = 'marker', + polygon = 'polygon', + 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); + } + +} + +export 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, L extends L.Layer = L.Layer> implements L.TB.DataLayer { + + protected settings: S; + + protected datasource: TbMapDatasource; + + protected mapDataId = guid(); + + protected dataLayerContainer: L.FeatureGroup; + + protected layerItems = new Map>(); + + protected groupsState: {[group: string]: boolean} = {}; + + protected enabled = true; + + public dataLayerLabelProcessor: DataLayerPatternProcessor; + public dataLayerTooltipProcessor: DataLayerPatternProcessor; + + protected constructor(protected map: TbMap, + inputSettings: S) { + this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings() as S, inputSettings); + if (this.settings.groups?.length) { + this.settings.groups.forEach((group) => { + this.groupsState[group] = true; + }); + } + this.dataLayerContainer = this.createDataLayerContainer(); + this.dataLayerLabelProcessor = this.settings.label.show ? new DataLayerPatternProcessor(this, this.settings.label) : null; + this.dataLayerTooltipProcessor = this.settings.tooltip.show ? new DataLayerPatternProcessor(this, this.settings.tooltip): null; + this.map.getMap().addLayer(this.dataLayerContainer); + } + + 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 forkJoin( + [ + this.dataLayerLabelProcessor ? this.dataLayerLabelProcessor.setup() : of(null), + this.dataLayerTooltipProcessor ? this.dataLayerTooltipProcessor.setup() : of(null), + this.doSetup() + ]); + } + + public getDatasource(): TbMapDatasource { + return this.datasource; + } + + public getDataLayerContainer(): L.FeatureGroup { + return this.dataLayerContainer; + } + + public getBounds(): LatLngBounds { + return this.dataLayerContainer.getBounds(); + } + + public isEnabled(): boolean { + return this.enabled; + } + + public getGroups(): string[] { + return this.settings.groups || []; + } + + public toggleGroup(group: string): boolean { + if (isDefined(this.groupsState[group])) { + this.groupsState[group] = !this.groupsState[group]; + const enabled = Object.values(this.groupsState).some(v => v); + if (this.enabled !== enabled) { + this.enabled = enabled; + if (this.enabled) { + this.map.getMap().addLayer(this.dataLayerContainer); + } else { + this.map.getMap().removeLayer(this.dataLayerContainer); + } + return true; + } + } + return false; + } + + public updateData(dsData: FormattedData[]) { + const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + const rawItems = layerData.filter(d => this.isValidLayerData(d)); + const toDelete = new Set(Array.from(this.layerItems.keys())); + const updatedItems: TbDataLayerItem[] = []; + rawItems.forEach((data) => { + let layerItem = this.layerItems.get(data.entityId); + if (layerItem) { + layerItem.update(data, dsData); + updatedItems.push(layerItem); + } else { + layerItem = this.createLayerItem(data, dsData); + this.layerItems.set(data.entityId, layerItem); + } + toDelete.delete(data.entityId); + }); + toDelete.forEach((key) => { + const item = this.layerItems.get(key); + item.remove(); + this.layerItems.delete(key); + }); + if (updatedItems.length) { + this.layerItemsUpdated(updatedItems); + } + } + + public getCtx(): WidgetContext { + return this.map.getCtx(); + } + public getMap(): TbMap { + return this.map; + } + + protected createDataLayerContainer(): L.FeatureGroup { + return L.featureGroup(); + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + return datasource; + } + + protected layerItemsUpdated(_updatedItems: TbDataLayerItem[]): void { + } + + protected mapType(): MapType { + return this.map.type(); + } + + public abstract dataLayerType(): MapDataLayerType; + + protected abstract defaultBaseSettings(): Partial; + + protected abstract doSetup(): Observable; + + protected abstract isValidLayerData(layerData: FormattedData): boolean; + + protected abstract createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem; + +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts new file mode 100644 index 0000000000..f97a6e31b3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -0,0 +1,506 @@ +/// +/// 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 { + BaseMarkerShapeSettings, + ClusterMarkerColorFunction, + DataLayerColorType, + defaultBaseMarkersDataLayerSettings, isValidLatLng, + loadImageWithAspect, + MapStringFunction, MapType, + MarkerIconInfo, + MarkerIconSettings, + MarkerImageFunction, + MarkerImageInfo, + MarkerImageSettings, + MarkerImageType, + MarkersDataLayerSettings, + MarkerShapeSettings, + MarkerType, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import L, { FeatureGroup } from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { CompiledTbFunction } from '@shared/models/js-function.models'; +import { isDefined, isDefinedAndNotNull, isEmptyStr, parseTbFunction, safeExecuteTbFunction } from '@core/utils'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import tinycolor from 'tinycolor2'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { + createColorMarkerIconElement, + 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 { MapDataLayerType, TbDataLayerItem, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +class TbMarkerDataLayerItem extends TbDataLayerItem { + + private location: L.LatLng; + private marker: L.Marker; + private labelOffset: L.PointTuple; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: MarkersDataLayerSettings, + protected dataLayer: TbMarkersDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Marker { + this.location = this.dataLayer.extractLocation(data); + this.marker = L.marker(this.location, { + tbMarkerData: data + }); + + this.updateMarkerIcon(data, dsData); + + return this.marker; + } + + protected createEventListeners(data: FormattedData, _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.marker.options.tbMarkerData = data; + this.updateTooltip(data, dsData); + this.updateMarkerIcon(data, dsData); + } + + private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { + this.dataLayer.markerIconProcessor.createMarkerIcon(data, dsData).subscribe( + (iconInfo) => { + this.marker.setIcon(iconInfo.icon); + const anchor = iconInfo.icon.options.iconAnchor; + if (anchor && Array.isArray(anchor)) { + this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; + } else { + this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; + } + this.updateLabel(data, dsData); + } + ); + } +} + +abstract class MarkerIconProcessor { + + static fromSettings(dataLayer: TbMarkersDataLayer, + settings: MarkersDataLayerSettings): MarkerIconProcessor { + switch (settings.markerType) { + case MarkerType.shape: + return new ShapeMarkerIconProcessor(dataLayer, settings.markerShape); + case MarkerType.icon: + return new IconMarkerIconProcessor(dataLayer, settings.markerIcon); + case MarkerType.image: + return new ImageMarkerIconProcessor(dataLayer, settings.markerImage); + } + } + + protected constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: S) {} + + public abstract setup(): Observable; + + public abstract createMarkerIcon(data: FormattedData, + dsData: FormattedData[]): Observable; + +} + +abstract class BaseColorMarkerShapeProcessor extends MarkerIconProcessor { + + private markerColorFunction: CompiledTbFunction; + + private defaultMarkerIconInfo: MarkerIconInfo; + + protected constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: S) { + super(dataLayer, settings); + } + + public setup(): Observable { + const colorSettings = this.settings.color; + if (colorSettings.type === DataLayerColorType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, colorSettings.colorFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.markerColorFunction = parsed; + return null; + }) + ); + } else { + const color = tinycolor(colorSettings.color); + return this.createMarkerShape(color, this.settings.size).pipe( + map((info) => { + this.defaultMarkerIconInfo = info; + return null; + } + )); + } + } + + public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { + const colorSettings = this.settings.color; + if (colorSettings.type === DataLayerColorType.function) { + const functionColor = safeExecuteTbFunction(this.markerColorFunction, [data, dsData]); + let color: tinycolor.Instance; + if (isDefinedAndNotNull(functionColor)) { + color = tinycolor(functionColor); + } else { + color = tinycolor(colorSettings.color); + } + return this.createMarkerShape(color, this.settings.size); + } else { + return of(this.defaultMarkerIconInfo); + } + } + + protected abstract createMarkerShape(color: tinycolor.Instance, size: number): Observable; +} + +class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor { + + constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: MarkerShapeSettings) { + super(dataLayer, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { + return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, size); + } + +} + +class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor { + + constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: MarkerIconSettings) { + super(dataLayer, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { + return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, size); + } + +} + +class ImageMarkerIconProcessor extends MarkerIconProcessor { + + private markerImageFunction: CompiledTbFunction; + + private defaultMarkerIconInfo: MarkerIconInfo; + + constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: MarkerImageSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + if (this.settings.type === MarkerImageType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe( + map((parsed) => { + this.markerImageFunction = parsed; + return null; + }) + ); + } else { + const currentImage: MarkerImageInfo = { + url: this.settings.image, + size: this.settings.imageSize || 34 + }; + return this.loadMarkerIconInfo(currentImage).pipe( + map((iconInfo) => { + this.defaultMarkerIconInfo = iconInfo; + return null; + } + )); + } + } + + public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { + if (this.settings.type === MarkerImageType.function) { + const currentImage: MarkerImageInfo = safeExecuteTbFunction(this.markerImageFunction, [data, this.settings.images, dsData]); + return this.loadMarkerIconInfo(currentImage); + } else { + return of(this.defaultMarkerIconInfo); + } + } + + private loadMarkerIconInfo(image: MarkerImageInfo): Observable { + if (image && image.url) { + return loadImageWithAspect(this.dataLayer.getCtx().$injector.get(ImagePipe), image.url).pipe( + switchMap((aspectImage) => { + if (aspectImage?.aspect) { + let width: number; + let height: number; + if (aspectImage.aspect > 1) { + width = image.size; + height = image.size / aspectImage.aspect; + } else { + width = image.size * aspectImage.aspect; + height = image.size; + } + let iconAnchor = image.markerOffset; + let popupAnchor = image.tooltipOffset; + if (!iconAnchor) { + iconAnchor = [width * this.dataLayer.markerOffset[0], height * this.dataLayer.markerOffset[1]]; + } + if (!popupAnchor) { + popupAnchor = [width * this.dataLayer.tooltipOffset[0], height * this.dataLayer.tooltipOffset[1]]; + } + const icon = L.icon({ + iconUrl: aspectImage.url, + iconSize: [width, height], + iconAnchor, + popupAnchor + }); + const iconInfo: MarkerIconInfo = { + size: [width, height], + icon + }; + return of(iconInfo); + } else { + return this.dataLayer.createDefaultMarkerIcon(); + } + }), + catchError(() => this.dataLayer.createDefaultMarkerIcon()) + ); + } else { + return this.dataLayer.createDefaultMarkerIcon(); + } + } + +} + +export class TbMarkersDataLayer extends TbMapDataLayer { + + public markerIconProcessor: MarkerIconProcessor; + + public markerOffset: L.LatLngTuple; + public tooltipOffset: L.LatLngTuple; + + private markersClusterContainer: L.MarkerClusterGroup; + private clusterMarkerColorFunction: CompiledTbFunction; + + constructor(protected map: TbMap, + inputSettings: MarkersDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.marker; + } + + protected createDataLayerContainer(): FeatureGroup { + if (this.settings.markerClustering?.enable) { + return this.createMarkersClusterContainer(); + } else { + return super.createDataLayerContainer(); + } + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.xKey, this.settings.yKey); + return datasource; + } + + protected defaultBaseSettings(): Partial { + return defaultBaseMarkersDataLayerSettings; + } + + protected doSetup(): Observable { + this.markerOffset = [ + isDefined(this.settings.markerOffsetX) ? this.settings.markerOffsetX : 0.5, + isDefined(this.settings.markerOffsetY) ? this.settings.markerOffsetY : 1, + ]; + this.tooltipOffset = [ + isDefined(this.settings.tooltip?.offsetX) ? this.settings.tooltip?.offsetX : 0, + isDefined(this.settings.tooltip?.offsetY) ? this.settings.tooltip?.offsetY : -1, + ]; + this.markerIconProcessor = MarkerIconProcessor.fromSettings(this, this.settings); + const setup$: Observable[] = []; + if (this.settings.markerClustering?.enable && this.settings.markerClustering.useClusterMarkerColorFunction) { + setup$.push( + parseTbFunction(this.getCtx().http, this.settings.markerClustering.clusterMarkerColorFunction, ['data', 'childCount']).pipe( + map((parsed) => { + this.clusterMarkerColorFunction = parsed; + return null; + }) + ) + ); + } + setup$.push(this.markerIconProcessor.setup()); + return forkJoin(setup$).pipe(map(() => null)); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return !!this.extractPosition(layerData); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbMarkerDataLayerItem { + return new TbMarkerDataLayerItem(data, dsData, this.settings, this); + } + + protected layerItemsUpdated(updatedItems: TbDataLayerItem[]) { + if (this.settings.markerClustering?.enable) { + this.markersClusterContainer.refreshClusters(updatedItems.map(item => item.getLayer())); + } + super.layerItemsUpdated(updatedItems); + } + + private createMarkersClusterContainer(): L.FeatureGroup { + const markerClusterOptions: L.MarkerClusterGroupOptions = { + spiderfyOnMaxZoom: this.settings.markerClustering?.spiderfyOnMaxZoom, + zoomToBoundsOnClick: this.settings.markerClustering?.zoomOnClick, + showCoverageOnHover: this.settings.markerClustering?.showCoverageOnHover, + removeOutsideVisibleBounds: this.settings.markerClustering?.lazyLoad, + animate: this.settings.markerClustering?.zoomAnimation, + chunkedLoading: this.settings.markerClustering?.chunkedLoad, + pmIgnore: true, + spiderLegPolylineOptions: { + pmIgnore: true + }, + polygonOptions: { + pmIgnore: true + } + }; + if (this.settings.markerClustering?.useClusterMarkerColorFunction) { + markerClusterOptions.iconCreateFunction = (cluster) => { + const childCount = cluster.getChildCount(); + const data = cluster.getAllChildMarkers().map(clusterMarker => clusterMarker.options.tbMarkerData); + const markerColor: string = this.clusterMarkerColorFunction ? + safeExecuteTbFunction(this.clusterMarkerColorFunction, [data, childCount]) : null; + if (isDefinedAndNotNull(markerColor) && tinycolor(markerColor).isValid()) { + const parsedColor = tinycolor(markerColor); + return L.divIcon({ + html: `
` + + `
` + childCount + '
', + iconSize: new L.Point(40, 40), + className: 'tb-cluster-marker-container' + }); + } else { + let c = ' marker-cluster-'; + if (childCount < 10) { + c += 'small'; + } else if (childCount < 100) { + c += 'medium'; + } else { + c += 'large'; + } + return new L.DivIcon({ + html: '
' + childCount + '
', + className: 'marker-cluster' + c, + iconSize: new L.Point(40, 40) + }); + } + } + } + if (this.settings.markerClustering?.maxClusterRadius && this.settings.markerClustering.maxClusterRadius > 0) { + markerClusterOptions.maxClusterRadius = Math.floor(this.settings.markerClustering.maxClusterRadius); + } + if (this.settings.markerClustering?.maxZoom && this.settings.markerClustering.maxZoom >= 0 && this.settings.markerClustering.maxZoom < 19) { + markerClusterOptions.disableClusteringAtZoom = Math.floor(this.settings.markerClustering.maxZoom); + } + this.markersClusterContainer = new L.MarkerClusterGroup(markerClusterOptions); + return this.markersClusterContainer; + } + + private extractPosition(data: FormattedData): {x: number; y: number} { + if (data) { + const xKeyVal = data[this.settings.xKey.label]; + const yKeyVal = data[this.settings.yKey.label]; + switch (this.mapType()) { + case MapType.geoMap: + if (!isValidLatLng(xKeyVal, yKeyVal)) { + return null; + } + break; + case MapType.image: + if (!isDefinedAndNotNull(xKeyVal) || isEmptyStr(xKeyVal) || isNaN(xKeyVal) || !isDefinedAndNotNull(yKeyVal) || isEmptyStr(yKeyVal) || isNaN(yKeyVal)) { + return null; + } + break; + } + return {x: xKeyVal, y: yKeyVal}; + } else { + return null; + } + } + + public createDefaultMarkerIcon(): Observable { + const color = this.settings.markerShape?.color?.color || '#307FE5'; + return this.createColoredMarkerShape(MarkerShape.markerShape1, tinycolor(color)); + } + + public createColoredMarkerShape(shape: MarkerShape, color: tinycolor.Instance, size = 34): Observable { + return createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), shape, color).pipe( + map((iconUrl) => { + return { + size: [size, size], + icon: L.icon({ + iconUrl, + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; + }) + ); + } + + public createColoredMarkerIcon(icon: string, color: tinycolor.Instance, size = 34): Observable { + return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color).pipe( + map((element) => { + return { + size: [size, size], + icon: L.divIcon({ + html: element.outerHTML, + className: 'tb-marker-div-icon', + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; + }) + ); + } + + public extractLocation(data: FormattedData): L.LatLng { + const position = this.extractPosition(data); + if (position) { + return this.map.positionToLatLng(position); + } else { + return null; + } + } +} 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 new file mode 100644 index 0000000000..de938a2809 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -0,0 +1,135 @@ +/// +/// 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 { + defaultBasePolygonsDataLayerSettings, + isCutPolygon, isJSON, + PolygonsDataLayerSettings, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import L from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { Observable } from 'rxjs'; +import { isNotEmptyStr, isString } from '@core/utils'; +import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +class TbPolygonDataLayerItem extends TbDataLayerItem { + + private polygonContainer: L.FeatureGroup; + private polygon: L.Polygon; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: PolygonsDataLayerSettings, + protected dataLayer: TbPolygonsDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, 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, { + ...style + }); + + 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, { + ...style + }); + this.polygon.addTo(this.polygonContainer); + } else { + this.polygon.setLatLngs(polyData); + } + } else if (polyData.length === 2) { + const bounds = new L.LatLngBounds(polyData); + // @ts-ignore + this.leafletPoly.setBounds(bounds); + } + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + this.polygon.setStyle(style); + } +} + +export class TbPolygonsDataLayer extends TbShapesDataLayer { + + constructor(protected map: TbMap, + inputSettings: PolygonsDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.polygon; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.polygonKey); + return datasource; + } + + protected defaultBaseSettings(): Partial { + return defaultBasePolygonsDataLayerSettings; + } + + protected doSetup(): Observable { + return super.doSetup(); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && ((isNotEmptyStr(layerData[this.settings.polygonKey.label]) && !isJSON(layerData[this.settings.polygonKey.label]) + || Array.isArray(layerData[this.settings.polygonKey.label]))); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbPolygonDataLayerItem { + return new TbPolygonDataLayerItem(data, dsData, this.settings, this); + } + + public extractPolygonCoordinates(data: FormattedData) { + let rawPolyData = data[this.settings.polygonKey.label]; + if (isString(rawPolyData)) { + rawPolyData = JSON.parse(rawPolyData); + } + return this.map.toPolygonCoordinates(rawPolyData); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts new file mode 100644 index 0000000000..30db8a0ac4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts @@ -0,0 +1,52 @@ +/// +/// 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 { ShapeDataLayerSettings, TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; +import L from 'leaflet'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { forkJoin, Observable } from 'rxjs'; +import { FormattedData } from '@shared/models/widget.models'; +import { DataLayerColorProcessor, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +export abstract class TbShapesDataLayer> 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); + return { + fill: true, + fillColor: fill, + color: stroke, + weight: this.settings.strokeWeight, + fillOpacity: 1, + opacity: 1 + }; + } +} 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 84b0d2a688..0aafc0889c 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 @@ -16,6 +16,11 @@ import L, { Coords, TB, TileLayerOptions } from 'leaflet'; import { guid } from '@core/utils'; +import 'leaflet-providers'; +import '@geoman-io/leaflet-geoman-free'; +import 'leaflet.markercluster'; + +L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true }); class SidebarControl extends L.Control { 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 deleted file mode 100644 index a730c74adf..0000000000 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts +++ /dev/null @@ -1,968 +0,0 @@ -/// -/// 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 { - BaseMarkerShapeSettings, - CirclesDataLayerSettings, - DataLayerColorSettings, - DataLayerColorType, - DataLayerPatternSettings, - DataLayerPatternType, - DataLayerTooltipTrigger, - defaultBaseCirclesDataLayerSettings, - defaultBaseMarkersDataLayerSettings, - defaultBasePolygonsDataLayerSettings, - isCutPolygon, - isJSON, - isValidLatLng, - loadImageWithAspect, - MapDataLayerSettings, - mapDataSourceSettingsToDatasource, - MapStringFunction, - MapType, - MarkerIconInfo, - MarkerIconSettings, - MarkerImageFunction, - MarkerImageInfo, - MarkerImageSettings, - MarkerImageType, - MarkersDataLayerSettings, - MarkerShapeSettings, - MarkerType, - 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 { forkJoin, Observable, of } from 'rxjs'; -import { - createLabelFromPattern, - guid, - isDefined, - isDefinedAndNotNull, - isEmptyStr, - isNotEmptyStr, - isString, - mergeDeepIgnoreArray, - parseTbFunction, - safeExecuteTbFunction -} from '@core/utils'; -import L, { divIcon, LatLngBounds } from 'leaflet'; -import { CompiledTbFunction } from '@shared/models/js-function.models'; -import { catchError, map, switchMap } 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'; -import { - createColorMarkerIconElement, - createColorMarkerShapeURI, - MarkerShape -} from '@home/components/widget/lib/maps/marker-shape.models'; -import { MatIconRegistry } from '@angular/material/icon'; -import { DomSanitizer } from '@angular/platform-browser'; - -abstract class TbDataLayerItem> { - - protected layer: L.Layer; - protected tooltip: L.Popup; - - 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 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); - }); - } - -} - -export enum MapDataLayerType { - marker = 'marker', - polygon = 'polygon', - 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; - - protected datasource: TbMapDatasource; - - protected mapDataId = guid(); - - protected featureGroup = L.featureGroup(); - - protected layerItems = new Map>(); - - protected groupsState: {[group: string]: boolean} = {}; - - protected enabled = true; - - public dataLayerLabelProcessor: DataLayerPatternProcessor; - public dataLayerTooltipProcessor: DataLayerPatternProcessor; - - protected constructor(protected map: TbMap, - inputSettings: S) { - this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings() as S, inputSettings); - if (this.settings.groups?.length) { - this.settings.groups.forEach((group) => { - this.groupsState[group] = true; - }); - } - this.dataLayerLabelProcessor = this.settings.label.show ? new DataLayerPatternProcessor(this, this.settings.label) : null; - this.dataLayerTooltipProcessor = this.settings.tooltip.show ? new DataLayerPatternProcessor(this, this.settings.tooltip): null; - this.map.getMap().addLayer(this.featureGroup); - } - - 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 forkJoin( - [ - this.dataLayerLabelProcessor ? this.dataLayerLabelProcessor.setup() : of(null), - this.dataLayerTooltipProcessor ? this.dataLayerTooltipProcessor.setup() : of(null), - this.doSetup() - ]); - } - - public getDatasource(): TbMapDatasource { - return this.datasource; - } - - public getFeatureGroup(): L.FeatureGroup { - return this.featureGroup; - } - - public getBounds(): LatLngBounds { - return this.featureGroup.getBounds(); - } - - public isEnabled(): boolean { - return this.enabled; - } - - public getGroups(): string[] { - return this.settings.groups || []; - } - - public toggleGroup(group: string): boolean { - if (isDefined(this.groupsState[group])) { - this.groupsState[group] = !this.groupsState[group]; - const enabled = Object.values(this.groupsState).some(v => v); - if (this.enabled !== enabled) { - this.enabled = enabled; - if (this.enabled) { - this.map.getMap().addLayer(this.featureGroup); - } else { - this.map.getMap().removeLayer(this.featureGroup); - } - return true; - } - } - return false; - } - - public updateData(dsData: FormattedData[]) { - const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); - const rawItems = layerData.filter(d => this.isValidLayerData(d)); - const toDelete = new Set(Array.from(this.layerItems.keys())); - rawItems.forEach((data) => { - let layerItem = this.layerItems.get(data.entityId); - if (layerItem) { - layerItem.update(data, dsData); - } else { - layerItem = this.createLayerItem(data, dsData); - this.layerItems.set(data.entityId, layerItem); - } - toDelete.delete(data.entityId); - }); - toDelete.forEach((key) => { - const item = this.layerItems.get(key); - item.remove(); - this.layerItems.delete(key); - }); - } - - public getCtx(): WidgetContext { - return this.map.getCtx(); - } - public getMap(): TbMap { - return this.map; - } - - protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { - return datasource; - } - - protected mapType(): MapType { - return this.map.type(); - } - - public abstract dataLayerType(): MapDataLayerType; - - protected abstract defaultBaseSettings(): Partial; - - protected abstract doSetup(): Observable; - - protected abstract isValidLayerData(layerData: FormattedData): boolean; - - protected abstract createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem; - -} - -class TbMarkerDataLayerItem extends TbDataLayerItem { - - private location: L.LatLng; - private marker: L.Marker; - private labelOffset: L.PointTuple; - - constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: MarkersDataLayerSettings, - protected dataLayer: TbMarkersDataLayer) { - super(data, dsData, settings, dataLayer); - } - - protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { - this.location = this.dataLayer.extractLocation(data); - this.marker = L.marker(this.location, { - tbMarkerData: data - }); - - this.updateMarkerIcon(data, dsData); - - return this.marker; - } - - protected createEventListeners(data: FormattedData, 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); - } - - private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { - this.dataLayer.markerIconProcessor.createMarkerIcon(data, dsData).subscribe( - (iconInfo) => { - this.marker.setIcon(iconInfo.icon); - const anchor = iconInfo.icon.options.iconAnchor; - if (anchor && Array.isArray(anchor)) { - this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; - } else { - this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; - } - this.updateLabel(data, dsData); - } - ); - } -} - -abstract class MarkerIconProcessor { - - static fromSettings(dataLayer: TbMarkersDataLayer, - settings: MarkersDataLayerSettings): MarkerIconProcessor { - switch (settings.markerType) { - case MarkerType.shape: - return new ShapeMarkerIconProcessor(dataLayer, settings.markerShape); - case MarkerType.icon: - return new IconMarkerIconProcessor(dataLayer, settings.markerIcon); - case MarkerType.image: - return new ImageMarkerIconProcessor(dataLayer, settings.markerImage); - } - } - - protected constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: S) {} - - public abstract setup(): Observable; - - public abstract createMarkerIcon(data: FormattedData, - dsData: FormattedData[]): Observable; - -} - -abstract class BaseColorMarkerShapeProcessor extends MarkerIconProcessor { - - private markerColorFunction: CompiledTbFunction; - - private defaultMarkerIconInfo: MarkerIconInfo; - - protected constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: S) { - super(dataLayer, settings); - } - - public setup(): Observable { - const colorSettings = this.settings.color; - if (colorSettings.type === DataLayerColorType.function) { - return parseTbFunction(this.dataLayer.getCtx().http, colorSettings.colorFunction, ['data', 'dsData']).pipe( - map((parsed) => { - this.markerColorFunction = parsed; - return null; - }) - ); - } else { - const color = tinycolor(colorSettings.color); - return this.createMarkerShape(color, this.settings.size).pipe( - map((info) => { - this.defaultMarkerIconInfo = info; - return null; - } - )); - } - } - - public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { - const colorSettings = this.settings.color; - if (colorSettings.type === DataLayerColorType.function) { - const functionColor = safeExecuteTbFunction(this.markerColorFunction, [data, dsData]); - let color: tinycolor.Instance; - if (isDefinedAndNotNull(functionColor)) { - color = tinycolor(functionColor); - } else { - color = tinycolor(colorSettings.color); - } - return this.createMarkerShape(color, this.settings.size); - } else { - return of(this.defaultMarkerIconInfo); - } - } - - protected abstract createMarkerShape(color: tinycolor.Instance, size: number): Observable; -} - -class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor { - - constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: MarkerShapeSettings) { - super(dataLayer, settings); - } - - protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { - return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, size); - } - -} - -class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor { - - constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: MarkerIconSettings) { - super(dataLayer, settings); - } - - protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { - return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, size); - } - -} - -class ImageMarkerIconProcessor extends MarkerIconProcessor { - - private markerImageFunction: CompiledTbFunction; - - private defaultMarkerIconInfo: MarkerIconInfo; - - constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: MarkerImageSettings) { - super(dataLayer, settings); - } - - public setup(): Observable { - if (this.settings.type === MarkerImageType.function) { - return parseTbFunction(this.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe( - map((parsed) => { - this.markerImageFunction = parsed; - return null; - }) - ); - } else { - const currentImage: MarkerImageInfo = { - url: this.settings.image, - size: this.settings.imageSize || 34 - }; - return this.loadMarkerIconInfo(currentImage).pipe( - map((iconInfo) => { - this.defaultMarkerIconInfo = iconInfo; - return null; - } - )); - } - } - - public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { - if (this.settings.type === MarkerImageType.function) { - const currentImage: MarkerImageInfo = safeExecuteTbFunction(this.markerImageFunction, [data, this.settings.images, dsData]); - return this.loadMarkerIconInfo(currentImage); - } else { - return of(this.defaultMarkerIconInfo); - } - } - - private loadMarkerIconInfo(image: MarkerImageInfo): Observable { - if (image && image.url) { - return loadImageWithAspect(this.dataLayer.getCtx().$injector.get(ImagePipe), image.url).pipe( - switchMap((aspectImage) => { - if (aspectImage?.aspect) { - let width: number; - let height: number; - if (aspectImage.aspect > 1) { - width = image.size; - height = image.size / aspectImage.aspect; - } else { - width = image.size * aspectImage.aspect; - height = image.size; - } - let iconAnchor = image.markerOffset; - let popupAnchor = image.tooltipOffset; - if (!iconAnchor) { - iconAnchor = [width * this.dataLayer.markerOffset[0], height * this.dataLayer.markerOffset[1]]; - } - if (!popupAnchor) { - popupAnchor = [width * this.dataLayer.tooltipOffset[0], height * this.dataLayer.tooltipOffset[1]]; - } - const icon = L.icon({ - iconUrl: aspectImage.url, - iconSize: [width, height], - iconAnchor, - popupAnchor - }); - const iconInfo: MarkerIconInfo = { - size: [width, height], - icon - }; - return of(iconInfo); - } else { - return this.dataLayer.createDefaultMarkerIcon(); - } - }), - catchError(() => this.dataLayer.createDefaultMarkerIcon()) - ); - } else { - return this.dataLayer.createDefaultMarkerIcon(); - } - } - -} - -export class TbMarkersDataLayer extends TbMapDataLayer { - - public markerIconProcessor: MarkerIconProcessor; - - public markerOffset: L.LatLngTuple; - public tooltipOffset: L.LatLngTuple; - - constructor(protected map: TbMap, - inputSettings: MarkersDataLayerSettings) { - super(map, inputSettings); - } - - public dataLayerType(): MapDataLayerType { - return MapDataLayerType.marker; - } - - protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { - datasource.dataKeys.push(this.settings.xKey, this.settings.yKey); - return datasource; - } - - protected defaultBaseSettings(): Partial { - return defaultBaseMarkersDataLayerSettings; - } - - protected doSetup(): Observable { - this.markerOffset = [ - isDefined(this.settings.markerOffsetX) ? this.settings.markerOffsetX : 0.5, - isDefined(this.settings.markerOffsetY) ? this.settings.markerOffsetY : 1, - ]; - this.tooltipOffset = [ - isDefined(this.settings.tooltip?.offsetX) ? this.settings.tooltip?.offsetX : 0, - isDefined(this.settings.tooltip?.offsetY) ? this.settings.tooltip?.offsetY : -1, - ]; - - this.markerIconProcessor = MarkerIconProcessor.fromSettings(this, this.settings); - return this.markerIconProcessor.setup(); - } - - protected isValidLayerData(layerData: FormattedData): boolean { - return !!this.extractPosition(layerData); - } - - protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbMarkerDataLayerItem { - return new TbMarkerDataLayerItem(data, dsData, this.settings, this); - } - - private extractPosition(data: FormattedData): {x: number; y: number} { - if (data) { - const xKeyVal = data[this.settings.xKey.label]; - const yKeyVal = data[this.settings.yKey.label]; - switch (this.mapType()) { - case MapType.geoMap: - if (!isValidLatLng(xKeyVal, yKeyVal)) { - return null; - } - break; - case MapType.image: - if (!isDefinedAndNotNull(xKeyVal) || isEmptyStr(xKeyVal) || isNaN(xKeyVal) || !isDefinedAndNotNull(yKeyVal) || isEmptyStr(yKeyVal) || isNaN(yKeyVal)) { - return null; - } - break; - } - return {x: xKeyVal, y: yKeyVal}; - } else { - return null; - } - } - - public createDefaultMarkerIcon(): Observable { - const color = this.settings.markerShape?.color?.color || '#307FE5'; - return this.createColoredMarkerShape(MarkerShape.markerShape1, tinycolor(color)); - } - - public createColoredMarkerShape(shape: MarkerShape, color: tinycolor.Instance, size = 34): Observable { - return createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), shape, color).pipe( - map((iconUrl) => { - return { - size: [size, size], - icon: L.icon({ - iconUrl, - iconSize: [size, size], - iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], - popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] - }) - }; - }) - ); - } - - public createColoredMarkerIcon(icon: string, color: tinycolor.Instance, size = 34): Observable { - return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color).pipe( - map((element) => { - return { - size: [size, size], - icon: L.divIcon({ - html: element.outerHTML, - className: 'tb-marker-div-icon', - iconSize: [size, size], - iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], - popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] - }) - }; - }) - ); - } - - public extractLocation(data: FormattedData): L.LatLng { - const position = this.extractPosition(data); - if (position) { - return this.map.positionToLatLng(position); - } else { - return null; - } - } -} - -class TbPolygonDataLayerItem extends TbDataLayerItem { - - private polygonContainer: L.FeatureGroup; - private polygon: L.Polygon; - - constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: PolygonsDataLayerSettings, - protected dataLayer: TbPolygonsDataLayer) { - super(data, dsData, settings, dataLayer); - } - - protected create(data: FormattedData, 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, { - ...style - }); - - 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, { - ...style - }); - this.polygon.addTo(this.polygonContainer); - } else { - this.polygon.setLatLngs(polyData); - } - } else if (polyData.length === 2) { - const bounds = new L.LatLngBounds(polyData); - // @ts-ignore - this.leafletPoly.setBounds(bounds); - } - this.updateTooltip(data, dsData); - this.updateLabel(data, dsData); - this.polygon.setStyle(style); - } -} - -abstract class TbShapesDataLayer> 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); - return { - fill: true, - fillColor: fill, - color: stroke, - weight: this.settings.strokeWeight, - fillOpacity: 1, - opacity: 1 - }; - } -} - -export class TbPolygonsDataLayer extends TbShapesDataLayer { - - constructor(protected map: TbMap, - inputSettings: PolygonsDataLayerSettings) { - super(map, inputSettings); - } - - public dataLayerType(): MapDataLayerType { - return MapDataLayerType.polygon; - } - - protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { - datasource.dataKeys.push(this.settings.polygonKey); - return datasource; - } - - protected defaultBaseSettings(): Partial { - return defaultBasePolygonsDataLayerSettings; - } - - protected doSetup(): Observable { - return super.doSetup(); - } - - protected isValidLayerData(layerData: FormattedData): boolean { - return layerData && ((isNotEmptyStr(layerData[this.settings.polygonKey.label]) && !isJSON(layerData[this.settings.polygonKey.label]) - || Array.isArray(layerData[this.settings.polygonKey.label]))); - } - - protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbPolygonDataLayerItem { - return new TbPolygonDataLayerItem(data, dsData, this.settings, this); - } - - public extractPolygonCoordinates(data: FormattedData) { - let rawPolyData = data[this.settings.polygonKey.label]; - if (isString(rawPolyData)) { - rawPolyData = JSON.parse(rawPolyData); - } - return this.map.toPolygonCoordinates(rawPolyData); - } -} - -class TbCircleDataLayerItem extends TbDataLayerItem { - - private circle: L.Circle; - - constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: CirclesDataLayerSettings, - protected dataLayer: TbCirclesDataLayer) { - super(data, dsData, settings, dataLayer); - } - - protected create(data: FormattedData, 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, - ...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); - if (!this.circle.getLatLng().equals(center)) { - this.circle.setLatLng(center); - } - if (this.circle.getRadius() !== circleData.radius) { - this.circle.setRadius(circleData.radius); - } - this.updateTooltip(data, dsData); - this.updateLabel(data, dsData); - const style = this.dataLayer.getShapeStyle(data, dsData); - this.circle.setStyle(style); - } -} - -export class TbCirclesDataLayer extends TbShapesDataLayer { - - constructor(protected map: TbMap, - inputSettings: CirclesDataLayerSettings) { - super(map, inputSettings); - } - - public dataLayerType(): MapDataLayerType { - return MapDataLayerType.circle; - } - - protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { - datasource.dataKeys.push(this.settings.circleKey); - return datasource; - } - - protected defaultBaseSettings(): Partial { - return defaultBaseCirclesDataLayerSettings; - } - - protected doSetup(): Observable { - return super.doSetup(); - } - - protected isValidLayerData(layerData: FormattedData): boolean { - return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]); - } - - protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem { - throw new TbCircleDataLayerItem(data, dsData, this.settings, this); - } - - public extractCircleCoordinates(data: FormattedData) { - const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); - return this.map.convertCircleData(circleData); - } - - -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts index ddea67c613..05ec4d3c19 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -28,7 +28,7 @@ import { MapProvider, OpenStreetMapLayerSettings, TencentMapLayerSettings -} from '@home/components/widget/lib/maps/map.models'; +} from '@home/components/widget/lib/maps/models/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; import { mergeDeep } from '@core/utils'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts index da394f6292..95f5e576c4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -32,7 +32,7 @@ import { WidgetContext } from '@home/models/widget-component.models'; import { Observable } from 'rxjs'; import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; -import { MapSetting } from '@home/components/widget/lib/maps/map.models'; +import { MapSetting } from '@home/components/widget/lib/maps/models/map.models'; import { WidgetComponent } from '@home/components/widget/widget.component'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts index bc0d790036..30daf48cdc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/map.models'; +import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/models/map.models'; import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; import { mergeDeep } from '@core/utils'; 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 2928f6205a..b44fcd75b2 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 @@ -101,6 +101,17 @@ border: none; } } + .tb-cluster-marker-container { + border: none; + background-color: transparent; + } + .tb-cluster-marker-element { + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 40px; + } } .tb-map-sidebar { .tb-layers, .tb-groups { 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 a85406daa9..6531c81839 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,8 @@ import { defaultImageMapSettings, GeoMapSettings, ImageMapSettings, - latLngPointToBounds, MapActionHandler, + latLngPointToBounds, + MapActionHandler, MapSetting, MapType, MapZoomAction, @@ -30,26 +31,23 @@ import { parseCenterPosition, TbCircleData, TbMapDatasource -} from '@home/components/widget/lib/maps/map.models'; +} from '@home/components/widget/lib/maps/models/map.models'; 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, LeafletMouseEvent, PointExpression, Projection } from 'leaflet'; +import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, 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'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; -import { - MapDataLayerType, - TbCirclesDataLayer, - TbMapDataLayer, - TbMarkersDataLayer, - TbPolygonsDataLayer -} from '@home/components/widget/lib/maps/map-data-layer'; +import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { Datasource, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; +import { WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; +import { TbPolygonsDataLayer } from '@home/components/widget/lib/maps/data-layer/polygons-data-layer'; +import { TbCirclesDataLayer } from '@home/components/widget/lib/maps/data-layer/circles-data-layer'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; export abstract class TbMap { @@ -277,7 +275,7 @@ export abstract class TbMap { side: ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left', distance: 2, trackOrigin: true, - functionBefore: (instance, helper) => { + functionBefore: (_instance, helper) => { if (helper.origin.ariaDisabled === 'true' || helper.origin.parentElement.classList.contains('active')) { return false; } @@ -478,7 +476,9 @@ class TbGeoMap extends TbMap { const map = L.map(this.mapElement, { scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), - zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons) + zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), + zoom: this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL, + center: this.defaultCenterPosition }).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL); return of(map); } 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/models/map.models.ts similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index f6cdd500e1..526c67945b 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/models/map.models.ts @@ -24,7 +24,7 @@ import { TbFunction } from '@shared/models/js-function.models'; 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/marker-shape.models'; +import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; export enum MapType { geoMap = 'geoMap', @@ -211,6 +211,20 @@ export interface MarkerShapeSettings extends BaseMarkerShapeSettings { export interface MarkerIconSettings extends BaseMarkerShapeSettings { icon: string; } +export interface MarkerClusteringSettings { + enable: boolean; + zoomOnClick: boolean; + maxZoom: number; + maxClusterRadius: number; + zoomAnimation: boolean; + showCoverageOnHover: boolean; + spiderfyOnMaxZoom: boolean; + chunkedLoad: boolean; + lazyLoad: boolean; + useClusterMarkerColorFunction: boolean; + clusterMarkerColorFunction: TbFunction; +} + export interface MarkersDataLayerSettings extends MapDataLayerSettings { xKey: DataKey; @@ -221,6 +235,7 @@ export interface MarkersDataLayerSettings extends MapDataLayerSettings { markerImage?: MarkerImageSettings; markerOffsetX: number; markerOffsetY: number; + markerClustering: MarkerClusteringSettings; } const defaultMarkerLatitudeFunction = 'var value = prevValue || 15.833293;\n' + @@ -292,7 +307,20 @@ export const defaultBaseMarkersDataLayerSettings: Partial, export type MarkerImageFunction = (data: FormattedData, markerImages: string[], dsData: FormattedData[]) => MarkerImageInfo; +export type ClusterMarkerColorFunction = (data: FormattedData[], childCount: number) => string; + export interface TbCircleData { latitude: number; longitude: number; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts index ff41d2772b..d8c64942ea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts @@ -22,7 +22,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/map.models'; +import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/models/map.models'; @Component({ selector: 'tb-data-layer-color-settings-panel', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts index 79249a5cee..0d7643fff1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts @@ -19,7 +19,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ComponentStyle } from '@shared/models/widget-settings.models'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; -import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/map.models'; +import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/models/map.models'; import { DataLayerColorSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component'; 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 index 204e1db59a..ee00d92043 100644 --- 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 @@ -32,7 +32,7 @@ import { DataLayerPatternSettings, DataLayerPatternType, DataLayerTooltipSettings, dataLayerTooltipTriggers, dataLayerTooltipTriggerTranslationMap -} from '@home/components/widget/lib/maps/map.models'; +} from '@home/components/widget/lib/maps/models/map.models'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ 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 b84d61f354..dc9c069882 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 @@ -251,6 +251,10 @@ formControlName="groups"> + +