36 changed files with 1536 additions and 1013 deletions
@ -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<CirclesDataLayerSettings, TbCirclesDataLayer> { |
|||
|
|||
private circle: L.Circle; |
|||
|
|||
constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
protected settings: CirclesDataLayerSettings, |
|||
protected dataLayer: TbCirclesDataLayer) { |
|||
super(data, dsData, settings, dataLayer); |
|||
} |
|||
|
|||
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<CirclesDataLayerSettings, TbCirclesDataLayer> { |
|||
|
|||
constructor(protected map: TbMap<any>, |
|||
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<CirclesDataLayerSettings> { |
|||
return defaultBaseCirclesDataLayerSettings; |
|||
} |
|||
|
|||
protected doSetup(): Observable<void> { |
|||
return super.doSetup(); |
|||
} |
|||
|
|||
protected isValidLayerData(layerData: FormattedData<TbMapDatasource>): boolean { |
|||
return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]); |
|||
} |
|||
|
|||
protected createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> { |
|||
throw new TbCircleDataLayerItem(data, dsData, this.settings, this); |
|||
} |
|||
|
|||
public extractCircleCoordinates(data: FormattedData<TbMapDatasource>) { |
|||
const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); |
|||
return this.map.convertCircleData(circleData); |
|||
} |
|||
|
|||
|
|||
} |
|||
@ -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<S extends MapDataLayerSettings, D extends TbMapDataLayer<S,D>, L extends L.Layer = L.Layer> { |
|||
|
|||
protected layer: L; |
|||
protected tooltip: L.Popup; |
|||
|
|||
protected constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L; |
|||
|
|||
protected abstract unbindLabel(): void; |
|||
|
|||
protected abstract bindLabel(content: L.Content): void; |
|||
|
|||
protected abstract createEventListeners(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void; |
|||
|
|||
public abstract update(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void; |
|||
|
|||
public remove() { |
|||
this.layer.off(); |
|||
this.dataLayer.getDataLayerContainer().removeLayer(this.layer); |
|||
} |
|||
|
|||
public getLayer(): L { |
|||
return this.layer; |
|||
} |
|||
|
|||
protected updateTooltip(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) { |
|||
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) { |
|||
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 = `<div style="color: ${labelColor};"><b>${label}</b></div>`; |
|||
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<MapStringFunction>; |
|||
private pattern: string; |
|||
|
|||
constructor(private dataLayer: TbMapDataLayer<any, any>, |
|||
private settings: DataLayerPatternSettings) {} |
|||
|
|||
public setup(): Observable<void> { |
|||
if (this.settings.type === DataLayerPatternType.function) { |
|||
return parseTbFunction<MapStringFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<MapStringFunction>; |
|||
private color: string; |
|||
|
|||
constructor(private dataLayer: TbMapDataLayer<any, any>, |
|||
private settings: DataLayerColorSettings) {} |
|||
|
|||
public setup(): Observable<void> { |
|||
if (this.settings.type === DataLayerColorType.function) { |
|||
return parseTbFunction<MapStringFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<S extends MapDataLayerSettings, D extends TbMapDataLayer<S,D>, 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<string, TbDataLayerItem<S,D,L>>(); |
|||
|
|||
protected groupsState: {[group: string]: boolean} = {}; |
|||
|
|||
protected enabled = true; |
|||
|
|||
public dataLayerLabelProcessor: DataLayerPatternProcessor; |
|||
public dataLayerTooltipProcessor: DataLayerPatternProcessor; |
|||
|
|||
protected constructor(protected map: TbMap<any>, |
|||
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<any> { |
|||
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<TbMapDatasource>[]) { |
|||
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<S,D,L>[] = []; |
|||
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<any> { |
|||
return this.map; |
|||
} |
|||
|
|||
protected createDataLayerContainer(): L.FeatureGroup { |
|||
return L.featureGroup(); |
|||
} |
|||
|
|||
protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { |
|||
return datasource; |
|||
} |
|||
|
|||
protected layerItemsUpdated(_updatedItems: TbDataLayerItem<S,D,L>[]): void { |
|||
} |
|||
|
|||
protected mapType(): MapType { |
|||
return this.map.type(); |
|||
} |
|||
|
|||
public abstract dataLayerType(): MapDataLayerType; |
|||
|
|||
protected abstract defaultBaseSettings(): Partial<S>; |
|||
|
|||
protected abstract doSetup(): Observable<any>; |
|||
|
|||
protected abstract isValidLayerData(layerData: FormattedData<TbMapDatasource>): boolean; |
|||
|
|||
protected abstract createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbDataLayerItem<S,D,L>; |
|||
|
|||
} |
|||
|
|||
@ -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<MarkersDataLayerSettings, TbMarkersDataLayer, L.Marker> { |
|||
|
|||
private location: L.LatLng; |
|||
private marker: L.Marker; |
|||
private labelOffset: L.PointTuple; |
|||
|
|||
constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
protected settings: MarkersDataLayerSettings, |
|||
protected dataLayer: TbMarkersDataLayer) { |
|||
super(data, dsData, settings, dataLayer); |
|||
} |
|||
|
|||
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) { |
|||
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<S> { |
|||
|
|||
static fromSettings(dataLayer: TbMarkersDataLayer, |
|||
settings: MarkersDataLayerSettings): MarkerIconProcessor<any> { |
|||
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<void>; |
|||
|
|||
public abstract createMarkerIcon(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo>; |
|||
|
|||
} |
|||
|
|||
abstract class BaseColorMarkerShapeProcessor<S extends BaseMarkerShapeSettings> extends MarkerIconProcessor<S> { |
|||
|
|||
private markerColorFunction: CompiledTbFunction<MapStringFunction>; |
|||
|
|||
private defaultMarkerIconInfo: MarkerIconInfo; |
|||
|
|||
protected constructor(protected dataLayer: TbMarkersDataLayer, |
|||
protected settings: S) { |
|||
super(dataLayer, settings); |
|||
} |
|||
|
|||
public setup(): Observable<void> { |
|||
const colorSettings = this.settings.color; |
|||
if (colorSettings.type === DataLayerColorType.function) { |
|||
return parseTbFunction<MapStringFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo> { |
|||
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<MarkerIconInfo>; |
|||
} |
|||
|
|||
class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor<MarkerShapeSettings> { |
|||
|
|||
constructor(protected dataLayer: TbMarkersDataLayer, |
|||
protected settings: MarkerShapeSettings) { |
|||
super(dataLayer, settings); |
|||
} |
|||
|
|||
protected createMarkerShape(color: tinycolor.Instance, size: number): Observable<MarkerIconInfo> { |
|||
return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, size); |
|||
} |
|||
|
|||
} |
|||
|
|||
class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor<MarkerIconSettings> { |
|||
|
|||
constructor(protected dataLayer: TbMarkersDataLayer, |
|||
protected settings: MarkerIconSettings) { |
|||
super(dataLayer, settings); |
|||
} |
|||
|
|||
protected createMarkerShape(color: tinycolor.Instance, size: number): Observable<MarkerIconInfo> { |
|||
return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, size); |
|||
} |
|||
|
|||
} |
|||
|
|||
class ImageMarkerIconProcessor extends MarkerIconProcessor<MarkerImageSettings> { |
|||
|
|||
private markerImageFunction: CompiledTbFunction<MarkerImageFunction>; |
|||
|
|||
private defaultMarkerIconInfo: MarkerIconInfo; |
|||
|
|||
constructor(protected dataLayer: TbMarkersDataLayer, |
|||
protected settings: MarkerImageSettings) { |
|||
super(dataLayer, settings); |
|||
} |
|||
|
|||
public setup(): Observable<void> { |
|||
if (this.settings.type === MarkerImageType.function) { |
|||
return parseTbFunction<MarkerImageFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo> { |
|||
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<MarkerIconInfo> { |
|||
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<MarkersDataLayerSettings, TbMarkersDataLayer> { |
|||
|
|||
public markerIconProcessor: MarkerIconProcessor<any>; |
|||
|
|||
public markerOffset: L.LatLngTuple; |
|||
public tooltipOffset: L.LatLngTuple; |
|||
|
|||
private markersClusterContainer: L.MarkerClusterGroup; |
|||
private clusterMarkerColorFunction: CompiledTbFunction<ClusterMarkerColorFunction>; |
|||
|
|||
constructor(protected map: TbMap<any>, |
|||
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<MarkersDataLayerSettings> { |
|||
return defaultBaseMarkersDataLayerSettings; |
|||
} |
|||
|
|||
protected doSetup(): Observable<void> { |
|||
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<void>[] = []; |
|||
if (this.settings.markerClustering?.enable && this.settings.markerClustering.useClusterMarkerColorFunction) { |
|||
setup$.push( |
|||
parseTbFunction<ClusterMarkerColorFunction>(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<TbMapDatasource>): boolean { |
|||
return !!this.extractPosition(layerData); |
|||
} |
|||
|
|||
protected createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbMarkerDataLayerItem { |
|||
return new TbMarkerDataLayerItem(data, dsData, this.settings, this); |
|||
} |
|||
|
|||
protected layerItemsUpdated(updatedItems: TbDataLayerItem<MarkersDataLayerSettings, TbMarkersDataLayer, L.Marker>[]) { |
|||
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: `<div style="background-color: ${parsedColor.setAlpha(0.4).toRgbString()};" ` + |
|||
`class="marker-cluster tb-cluster-marker-element">` + |
|||
`<div style="background-color: ${parsedColor.setAlpha(0.9).toRgbString()};"><span>` + childCount + '</span></div></div>', |
|||
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: '<div><span>' + childCount + '</span></div>', |
|||
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<TbMapDatasource>): {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<MarkerIconInfo> { |
|||
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<MarkerIconInfo> { |
|||
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<MarkerIconInfo> { |
|||
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<TbMapDatasource>): L.LatLng { |
|||
const position = this.extractPosition(data); |
|||
if (position) { |
|||
return this.map.positionToLatLng(position); |
|||
} else { |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
@ -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<PolygonsDataLayerSettings, TbPolygonsDataLayer> { |
|||
|
|||
private polygonContainer: L.FeatureGroup; |
|||
private polygon: L.Polygon; |
|||
|
|||
constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
protected settings: PolygonsDataLayerSettings, |
|||
protected dataLayer: TbPolygonsDataLayer) { |
|||
super(data, dsData, settings, dataLayer); |
|||
} |
|||
|
|||
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<PolygonsDataLayerSettings, TbPolygonsDataLayer> { |
|||
|
|||
constructor(protected map: TbMap<any>, |
|||
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<PolygonsDataLayerSettings> { |
|||
return defaultBasePolygonsDataLayerSettings; |
|||
} |
|||
|
|||
protected doSetup(): Observable<any> { |
|||
return super.doSetup(); |
|||
} |
|||
|
|||
protected isValidLayerData(layerData: FormattedData<TbMapDatasource>): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbPolygonDataLayerItem { |
|||
return new TbPolygonDataLayerItem(data, dsData, this.settings, this); |
|||
} |
|||
|
|||
public extractPolygonCoordinates(data: FormattedData<TbMapDatasource>) { |
|||
let rawPolyData = data[this.settings.polygonKey.label]; |
|||
if (isString(rawPolyData)) { |
|||
rawPolyData = JSON.parse(rawPolyData); |
|||
} |
|||
return this.map.toPolygonCoordinates(rawPolyData); |
|||
} |
|||
} |
|||
@ -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<S extends ShapeDataLayerSettings, L extends TbMapDataLayer<S,L>> extends TbMapDataLayer<S, L> { |
|||
|
|||
public fillColorProcessor: DataLayerColorProcessor; |
|||
public strokeColorProcessor: DataLayerColorProcessor; |
|||
|
|||
protected constructor(protected map: TbMap<any>, |
|||
inputSettings: S) { |
|||
super(map, inputSettings); |
|||
} |
|||
|
|||
protected doSetup(): Observable<any> { |
|||
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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 |
|||
}; |
|||
} |
|||
} |
|||
@ -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<S extends MapDataLayerSettings, L extends TbMapDataLayer<S,L>> { |
|||
|
|||
protected layer: L.Layer; |
|||
protected tooltip: L.Popup; |
|||
|
|||
protected constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer; |
|||
|
|||
protected abstract unbindLabel(): void; |
|||
|
|||
protected abstract bindLabel(content: L.Content): void; |
|||
|
|||
protected abstract createEventListeners(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void; |
|||
|
|||
public abstract update(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void; |
|||
|
|||
public remove() { |
|||
this.layer.off(); |
|||
this.dataLayer.getFeatureGroup().removeLayer(this.layer); |
|||
} |
|||
|
|||
protected updateTooltip(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) { |
|||
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) { |
|||
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 = `<div style="color: ${labelColor};"><b>${label}</b></div>`; |
|||
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<MapStringFunction>; |
|||
private pattern: string; |
|||
|
|||
constructor(private dataLayer: TbMapDataLayer<any, any>, |
|||
private settings: DataLayerPatternSettings) {} |
|||
|
|||
public setup(): Observable<void> { |
|||
if (this.settings.type === DataLayerPatternType.function) { |
|||
return parseTbFunction<MapStringFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<MapStringFunction>; |
|||
private color: string; |
|||
|
|||
constructor(private dataLayer: TbMapDataLayer<any, any>, |
|||
private settings: DataLayerColorSettings) {} |
|||
|
|||
public setup(): Observable<void> { |
|||
if (this.settings.type === DataLayerColorType.function) { |
|||
return parseTbFunction<MapStringFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<S extends MapDataLayerSettings, L extends TbMapDataLayer<S,L>> implements L.TB.DataLayer { |
|||
|
|||
protected settings: S; |
|||
|
|||
protected datasource: TbMapDatasource; |
|||
|
|||
protected mapDataId = guid(); |
|||
|
|||
protected featureGroup = L.featureGroup(); |
|||
|
|||
protected layerItems = new Map<string, TbDataLayerItem<S,L>>(); |
|||
|
|||
protected groupsState: {[group: string]: boolean} = {}; |
|||
|
|||
protected enabled = true; |
|||
|
|||
public dataLayerLabelProcessor: DataLayerPatternProcessor; |
|||
public dataLayerTooltipProcessor: DataLayerPatternProcessor; |
|||
|
|||
protected constructor(protected map: TbMap<any>, |
|||
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<any> { |
|||
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<TbMapDatasource>[]) { |
|||
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<any> { |
|||
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<S>; |
|||
|
|||
protected abstract doSetup(): Observable<any>; |
|||
|
|||
protected abstract isValidLayerData(layerData: FormattedData<TbMapDatasource>): boolean; |
|||
|
|||
protected abstract createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbDataLayerItem<S,L>; |
|||
|
|||
} |
|||
|
|||
class TbMarkerDataLayerItem extends TbDataLayerItem<MarkersDataLayerSettings, TbMarkersDataLayer> { |
|||
|
|||
private location: L.LatLng; |
|||
private marker: L.Marker; |
|||
private labelOffset: L.PointTuple; |
|||
|
|||
constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
protected settings: MarkersDataLayerSettings, |
|||
protected dataLayer: TbMarkersDataLayer) { |
|||
super(data, dsData, settings, dataLayer); |
|||
} |
|||
|
|||
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) { |
|||
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<S> { |
|||
|
|||
static fromSettings(dataLayer: TbMarkersDataLayer, |
|||
settings: MarkersDataLayerSettings): MarkerIconProcessor<any> { |
|||
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<void>; |
|||
|
|||
public abstract createMarkerIcon(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo>; |
|||
|
|||
} |
|||
|
|||
abstract class BaseColorMarkerShapeProcessor<S extends BaseMarkerShapeSettings> extends MarkerIconProcessor<S> { |
|||
|
|||
private markerColorFunction: CompiledTbFunction<MapStringFunction>; |
|||
|
|||
private defaultMarkerIconInfo: MarkerIconInfo; |
|||
|
|||
protected constructor(protected dataLayer: TbMarkersDataLayer, |
|||
protected settings: S) { |
|||
super(dataLayer, settings); |
|||
} |
|||
|
|||
public setup(): Observable<void> { |
|||
const colorSettings = this.settings.color; |
|||
if (colorSettings.type === DataLayerColorType.function) { |
|||
return parseTbFunction<MapStringFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo> { |
|||
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<MarkerIconInfo>; |
|||
} |
|||
|
|||
class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor<MarkerShapeSettings> { |
|||
|
|||
constructor(protected dataLayer: TbMarkersDataLayer, |
|||
protected settings: MarkerShapeSettings) { |
|||
super(dataLayer, settings); |
|||
} |
|||
|
|||
protected createMarkerShape(color: tinycolor.Instance, size: number): Observable<MarkerIconInfo> { |
|||
return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, size); |
|||
} |
|||
|
|||
} |
|||
|
|||
class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor<MarkerIconSettings> { |
|||
|
|||
constructor(protected dataLayer: TbMarkersDataLayer, |
|||
protected settings: MarkerIconSettings) { |
|||
super(dataLayer, settings); |
|||
} |
|||
|
|||
protected createMarkerShape(color: tinycolor.Instance, size: number): Observable<MarkerIconInfo> { |
|||
return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, size); |
|||
} |
|||
|
|||
} |
|||
|
|||
class ImageMarkerIconProcessor extends MarkerIconProcessor<MarkerImageSettings> { |
|||
|
|||
private markerImageFunction: CompiledTbFunction<MarkerImageFunction>; |
|||
|
|||
private defaultMarkerIconInfo: MarkerIconInfo; |
|||
|
|||
constructor(protected dataLayer: TbMarkersDataLayer, |
|||
protected settings: MarkerImageSettings) { |
|||
super(dataLayer, settings); |
|||
} |
|||
|
|||
public setup(): Observable<void> { |
|||
if (this.settings.type === MarkerImageType.function) { |
|||
return parseTbFunction<MarkerImageFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo> { |
|||
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<MarkerIconInfo> { |
|||
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<MarkersDataLayerSettings, TbMarkersDataLayer> { |
|||
|
|||
public markerIconProcessor: MarkerIconProcessor<any>; |
|||
|
|||
public markerOffset: L.LatLngTuple; |
|||
public tooltipOffset: L.LatLngTuple; |
|||
|
|||
constructor(protected map: TbMap<any>, |
|||
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<MarkersDataLayerSettings> { |
|||
return defaultBaseMarkersDataLayerSettings; |
|||
} |
|||
|
|||
protected doSetup(): Observable<void> { |
|||
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<TbMapDatasource>): boolean { |
|||
return !!this.extractPosition(layerData); |
|||
} |
|||
|
|||
protected createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbMarkerDataLayerItem { |
|||
return new TbMarkerDataLayerItem(data, dsData, this.settings, this); |
|||
} |
|||
|
|||
private extractPosition(data: FormattedData<TbMapDatasource>): {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<MarkerIconInfo> { |
|||
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<MarkerIconInfo> { |
|||
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<MarkerIconInfo> { |
|||
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<TbMapDatasource>): L.LatLng { |
|||
const position = this.extractPosition(data); |
|||
if (position) { |
|||
return this.map.positionToLatLng(position); |
|||
} else { |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
|
|||
class TbPolygonDataLayerItem extends TbDataLayerItem<PolygonsDataLayerSettings, TbPolygonsDataLayer> { |
|||
|
|||
private polygonContainer: L.FeatureGroup; |
|||
private polygon: L.Polygon; |
|||
|
|||
constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
protected settings: PolygonsDataLayerSettings, |
|||
protected dataLayer: TbPolygonsDataLayer) { |
|||
super(data, dsData, settings, dataLayer); |
|||
} |
|||
|
|||
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<S extends ShapeDataLayerSettings, L extends TbMapDataLayer<S,L>> extends TbMapDataLayer<S, L> { |
|||
|
|||
public fillColorProcessor: DataLayerColorProcessor; |
|||
public strokeColorProcessor: DataLayerColorProcessor; |
|||
|
|||
protected constructor(protected map: TbMap<any>, |
|||
inputSettings: S) { |
|||
super(map, inputSettings); |
|||
} |
|||
|
|||
protected doSetup(): Observable<any> { |
|||
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<PolygonsDataLayerSettings, TbPolygonsDataLayer> { |
|||
|
|||
constructor(protected map: TbMap<any>, |
|||
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<PolygonsDataLayerSettings> { |
|||
return defaultBasePolygonsDataLayerSettings; |
|||
} |
|||
|
|||
protected doSetup(): Observable<any> { |
|||
return super.doSetup(); |
|||
} |
|||
|
|||
protected isValidLayerData(layerData: FormattedData<TbMapDatasource>): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbPolygonDataLayerItem { |
|||
return new TbPolygonDataLayerItem(data, dsData, this.settings, this); |
|||
} |
|||
|
|||
public extractPolygonCoordinates(data: FormattedData<TbMapDatasource>) { |
|||
let rawPolyData = data[this.settings.polygonKey.label]; |
|||
if (isString(rawPolyData)) { |
|||
rawPolyData = JSON.parse(rawPolyData); |
|||
} |
|||
return this.map.toPolygonCoordinates(rawPolyData); |
|||
} |
|||
} |
|||
|
|||
class TbCircleDataLayerItem extends TbDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> { |
|||
|
|||
private circle: L.Circle; |
|||
|
|||
constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
protected settings: CirclesDataLayerSettings, |
|||
protected dataLayer: TbCirclesDataLayer) { |
|||
super(data, dsData, settings, dataLayer); |
|||
} |
|||
|
|||
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<CirclesDataLayerSettings, TbCirclesDataLayer> { |
|||
|
|||
constructor(protected map: TbMap<any>, |
|||
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<CirclesDataLayerSettings> { |
|||
return defaultBaseCirclesDataLayerSettings; |
|||
} |
|||
|
|||
protected doSetup(): Observable<void> { |
|||
return super.doSetup(); |
|||
} |
|||
|
|||
protected isValidLayerData(layerData: FormattedData<TbMapDatasource>): boolean { |
|||
return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]); |
|||
} |
|||
|
|||
protected createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> { |
|||
throw new TbCircleDataLayerItem(data, dsData, this.settings, this); |
|||
} |
|||
|
|||
public extractCircleCoordinates(data: FormattedData<TbMapDatasource>) { |
|||
const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); |
|||
return this.map.convertCircleData(circleData); |
|||
} |
|||
|
|||
|
|||
} |
|||
@ -0,0 +1,98 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<div class="tb-form-panel tb-slide-toggle" [formGroup]="clusteringSettingsFormGroup"> |
|||
<mat-expansion-panel class="tb-settings" [expanded]="settingsExpanded" |
|||
[disabled]="!clusteringSettingsFormGroup.get('enable').value"> |
|||
<mat-expansion-panel-header class="flex flex-row flex-wrap"> |
|||
<mat-panel-title> |
|||
<mat-slide-toggle class="mat-slide flex items-stretch justify-center" formControlName="enable" (click)="$event.stopPropagation()"> |
|||
{{ 'widgets.maps.data-layer.marker.clustering.use-map-markers-clustering' | translate }} |
|||
</mat-slide-toggle> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="zoomOnClick"> |
|||
{{ 'widgets.maps.data-layer.marker.clustering.zoom-on-cluster-click' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div translate>widgets.maps.data-layer.marker.clustering.max-zoom</div> |
|||
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic"> |
|||
<input matInput type="number" min="0" max="18" step="1" formControlName="maxZoom" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div translate>widgets.maps.data-layer.marker.clustering.max-radius</div> |
|||
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic"> |
|||
<input matInput type="number" min="0" formControlName="maxClusterRadius" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
<span matSuffix>px</span> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="zoomAnimation"> |
|||
{{ 'widgets.maps.data-layer.marker.clustering.zoom-animation' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="showCoverageOnHover"> |
|||
{{ 'widgets.maps.data-layer.marker.clustering.bounds-on-cluster-mouse-over' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="spiderfyOnMaxZoom"> |
|||
{{ 'widgets.maps.data-layer.marker.clustering.spiderfy-max-zoom-level' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-panel stroked"> |
|||
<div class="tb-form-panel-title" translate>widgets.maps.data-layer.marker.clustering.load-optimization</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="chunkedLoad"> |
|||
{{ 'widgets.maps.data-layer.marker.clustering.chunked-load' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="lazyLoad"> |
|||
{{ 'widgets.maps.data-layer.marker.clustering.lazy-load' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-panel stroked tb-slide-toggle"> |
|||
<mat-expansion-panel class="tb-settings" [expanded]="clusteringSettingsFormGroup.get('useClusterMarkerColorFunction').value" |
|||
[disabled]="!clusteringSettingsFormGroup.get('useClusterMarkerColorFunction').value"> |
|||
<mat-expansion-panel-header class="flex flex-row flex-wrap"> |
|||
<mat-panel-title> |
|||
<mat-slide-toggle class="mat-slide flex items-stretch justify-center" formControlName="useClusterMarkerColorFunction" (click)="$event.stopPropagation()"> |
|||
{{ 'widgets.maps.data-layer.marker.clustering.use-cluster-marker-color-function' | translate }} |
|||
</mat-slide-toggle> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
<tb-js-func formControlName="clusterMarkerColorFunction" |
|||
withModules |
|||
[globalVariables]="functionScopeVariables" |
|||
[functionArgs]="['data', 'childCount']" |
|||
functionTitle="{{ 'widgets.maps.data-layer.marker.clustering.marker-color-function' | translate }}" |
|||
helpId="widget/lib/map/clustering_color_fn"> |
|||
</tb-js-func> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
@ -0,0 +1,156 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; |
|||
import { |
|||
ControlValueAccessor, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
UntypedFormBuilder, |
|||
UntypedFormControl, |
|||
UntypedFormGroup, |
|||
Validator, |
|||
Validators |
|||
} from '@angular/forms'; |
|||
import { WidgetService } from '@core/http/widget.service'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { MarkerClusteringSettings } from '@home/components/widget/lib/maps/models/map.models'; |
|||
import { merge } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-marker-clustering-settings', |
|||
templateUrl: './marker-clustering-settings.component.html', |
|||
styleUrls: ['./../../widget-settings.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => MarkerClusteringSettingsComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => MarkerClusteringSettingsComponent), |
|||
multi: true |
|||
} |
|||
] |
|||
}) |
|||
export class MarkerClusteringSettingsComponent implements OnInit, ControlValueAccessor, Validator { |
|||
|
|||
settingsExpanded = false; |
|||
|
|||
functionScopeVariables = this.widgetService.getWidgetScopeVariables(); |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
private modelValue: MarkerClusteringSettings; |
|||
|
|||
private propagateChange = null; |
|||
|
|||
public clusteringSettingsFormGroup: UntypedFormGroup; |
|||
|
|||
constructor(private fb: UntypedFormBuilder, |
|||
private widgetService: WidgetService, |
|||
private destroyRef: DestroyRef) { |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
|
|||
this.clusteringSettingsFormGroup = this.fb.group({ |
|||
enable: [null, []], |
|||
zoomOnClick: [null, []], |
|||
maxZoom: [null, [Validators.min(0), Validators.max(18)]], |
|||
maxClusterRadius: [null, [Validators.min(0)]], |
|||
zoomAnimation: [null, []], |
|||
showCoverageOnHover: [null, []], |
|||
spiderfyOnMaxZoom: [null, []], |
|||
chunkedLoad: [null, []], |
|||
lazyLoad: [null, []], |
|||
useClusterMarkerColorFunction: [null, []], |
|||
clusterMarkerColorFunction: [null, []] |
|||
}); |
|||
this.clusteringSettingsFormGroup.valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe(() => { |
|||
this.updateModel(); |
|||
}); |
|||
merge(this.clusteringSettingsFormGroup.get('enable').valueChanges, |
|||
this.clusteringSettingsFormGroup.get('useClusterMarkerColorFunction').valueChanges).pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe(() => { |
|||
this.updateValidators(); |
|||
}); |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(_fn: any): void { |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (isDisabled) { |
|||
this.clusteringSettingsFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.clusteringSettingsFormGroup.enable({emitEvent: false}); |
|||
this.updateValidators(); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: MarkerClusteringSettings): void { |
|||
this.modelValue = value; |
|||
this.clusteringSettingsFormGroup.patchValue( |
|||
value, {emitEvent: false} |
|||
); |
|||
this.settingsExpanded = this.clusteringSettingsFormGroup.get('enable').value; |
|||
this.updateValidators(); |
|||
this.clusteringSettingsFormGroup.get('enable').valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe((enable) => { |
|||
this.settingsExpanded = enable; |
|||
}); |
|||
} |
|||
|
|||
public validate(c: UntypedFormControl) { |
|||
const valid = this.clusteringSettingsFormGroup.valid; |
|||
return valid ? null : { |
|||
markerClustering: { |
|||
valid: false, |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
private updateValidators() { |
|||
const enable: boolean = this.clusteringSettingsFormGroup.get('enable').value; |
|||
const useClusterMarkerColorFunction: boolean = this.clusteringSettingsFormGroup.get('useClusterMarkerColorFunction').value; |
|||
if (enable) { |
|||
this.clusteringSettingsFormGroup.enable({emitEvent: false}); |
|||
if (!useClusterMarkerColorFunction) { |
|||
this.clusteringSettingsFormGroup.get('clusterMarkerColorFunction').disable({emitEvent: false}); |
|||
} |
|||
} else { |
|||
this.clusteringSettingsFormGroup.disable({emitEvent: false}); |
|||
this.clusteringSettingsFormGroup.get('enable').enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
private updateModel() { |
|||
this.modelValue = this.clusteringSettingsFormGroup.getRawValue(); |
|||
this.propagateChange(this.modelValue); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue