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