Browse Source

UI: Implement map markers clustering.

pull/12723/head
Igor Kulikov 2 years ago
parent
commit
cd8d172e12
  1. 8
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts
  2. 4
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts
  3. 120
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts
  4. 361
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts
  5. 506
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts
  6. 135
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts
  7. 52
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts
  8. 5
      ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts
  9. 968
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts
  10. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts
  11. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts
  12. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts
  13. 11
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss
  14. 26
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts
  15. 34
      ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts
  16. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts
  17. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts
  18. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts
  19. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts
  20. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html
  21. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts
  22. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts
  23. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts
  24. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts
  25. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts
  26. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts
  27. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts
  28. 98
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.html
  29. 156
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts
  30. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts
  31. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts
  32. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts
  33. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts
  34. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  35. 16
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  36. 4
      ui-ngx/src/typings/leaflet-extend-tb.d.ts

8
ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts

@ -16,9 +16,7 @@
import L, { FeatureGroup, LatLngBounds, LatLngTuple, PointExpression, Projection } from 'leaflet';
import tinycolor from 'tinycolor2';
import 'leaflet-providers';
import 'leaflet.markercluster';
import '@geoman-io/leaflet-geoman-free';
import '@home/components/widget/lib/maps/leaflet/leaflet-tb';
import {
CircleData,
@ -125,10 +123,6 @@ export default abstract class LeafletMap {
private initMarkerClusterSettings() {
const markerClusteringSettings: WidgetMarkerClusteringSettings = this.options;
if (markerClusteringSettings.useClusterMarkers) {
// disabled marker cluster icon
(L as any).MarkerCluster = (L as any).MarkerCluster.extend({
options: { pmIgnore: true, ...L.Icon.prototype.options }
});
this.clusteringSettings = {
spiderfyOnMaxZoom: markerClusteringSettings.spiderfyOnMaxZoom,
zoomToBoundsOnClick: markerClusteringSettings.zoomOnClick,

4
ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts

@ -52,7 +52,7 @@ export class Marker {
this.leafletMarker = L.marker(this.location, {
pmIgnore: !settings.draggableMarker,
snapIgnore: !snappable,
tbMarkerData: this.data
tbMarkerData: this.data as any
});
this.markerOffset = [
@ -101,7 +101,7 @@ export class Marker {
setDataSources(data: FormattedData, dataSources: FormattedData[]) {
this.data = data;
this.dataSources = dataSources;
this.leafletMarker.options.tbMarkerData = data;
this.leafletMarker.options.tbMarkerData = data as any;
}
updateMarkerTooltip(data: FormattedData) {

120
ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts

@ -0,0 +1,120 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
CirclesDataLayerSettings,
defaultBaseCirclesDataLayerSettings, isJSON, TbCircleData,
TbMapDatasource
} from '@home/components/widget/lib/maps/models/map.models';
import L from 'leaflet';
import { FormattedData } from '@shared/models/widget.models';
import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { Observable } from 'rxjs';
import { isNotEmptyStr } from '@core/utils';
import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
class TbCircleDataLayerItem extends TbDataLayerItem<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);
}
}

361
ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts

@ -0,0 +1,361 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
DataLayerColorSettings,
DataLayerColorType,
DataLayerPatternSettings,
DataLayerPatternType,
DataLayerTooltipTrigger,
MapDataLayerSettings,
mapDataSourceSettingsToDatasource,
MapStringFunction,
MapType,
processTooltipTemplate,
TbMapDatasource
} from '@home/components/widget/lib/maps/models/map.models';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { FormattedData } from '@shared/models/widget.models';
import { forkJoin, Observable, of } from 'rxjs';
import {
createLabelFromPattern,
guid,
isDefined,
mergeDeepIgnoreArray,
parseTbFunction,
safeExecuteTbFunction
} from '@core/utils';
import L, { LatLngBounds } from 'leaflet';
import { CompiledTbFunction } from '@shared/models/js-function.models';
import { map } from 'rxjs/operators';
import { WidgetContext } from '@home/models/widget-component.models';
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
export abstract class TbDataLayerItem<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>;
}

506
ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts

@ -0,0 +1,506 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
BaseMarkerShapeSettings,
ClusterMarkerColorFunction,
DataLayerColorType,
defaultBaseMarkersDataLayerSettings, isValidLatLng,
loadImageWithAspect,
MapStringFunction, MapType,
MarkerIconInfo,
MarkerIconSettings,
MarkerImageFunction,
MarkerImageInfo,
MarkerImageSettings,
MarkerImageType,
MarkersDataLayerSettings,
MarkerShapeSettings,
MarkerType,
TbMapDatasource
} from '@home/components/widget/lib/maps/models/map.models';
import L, { FeatureGroup } from 'leaflet';
import { FormattedData } from '@shared/models/widget.models';
import { forkJoin, Observable, of } from 'rxjs';
import { CompiledTbFunction } from '@shared/models/js-function.models';
import { isDefined, isDefinedAndNotNull, isEmptyStr, parseTbFunction, safeExecuteTbFunction } from '@core/utils';
import { catchError, map, switchMap } from 'rxjs/operators';
import tinycolor from 'tinycolor2';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { TbMap } from '@home/components/widget/lib/maps/map';
import {
createColorMarkerIconElement,
createColorMarkerShapeURI,
MarkerShape
} from '@home/components/widget/lib/maps/models/marker-shape.models';
import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
import { MapDataLayerType, TbDataLayerItem, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
class TbMarkerDataLayerItem extends TbDataLayerItem<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;
}
}
}

135
ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts

@ -0,0 +1,135 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
defaultBasePolygonsDataLayerSettings,
isCutPolygon, isJSON,
PolygonsDataLayerSettings,
TbMapDatasource
} from '@home/components/widget/lib/maps/models/map.models';
import L from 'leaflet';
import { FormattedData } from '@shared/models/widget.models';
import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { Observable } from 'rxjs';
import { isNotEmptyStr, isString } from '@core/utils';
import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
class TbPolygonDataLayerItem extends TbDataLayerItem<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);
}
}

52
ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts

@ -0,0 +1,52 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ShapeDataLayerSettings, TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models';
import L from 'leaflet';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { forkJoin, Observable } from 'rxjs';
import { FormattedData } from '@shared/models/widget.models';
import { DataLayerColorProcessor, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
export abstract class TbShapesDataLayer<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
};
}
}

5
ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts

@ -16,6 +16,11 @@
import L, { Coords, TB, TileLayerOptions } from 'leaflet';
import { guid } from '@core/utils';
import 'leaflet-providers';
import '@geoman-io/leaflet-geoman-free';
import 'leaflet.markercluster';
L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true });
class SidebarControl extends L.Control<TB.SidebarControlOptions> {

968
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts

@ -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);
}
}

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts

@ -28,7 +28,7 @@ import {
MapProvider,
OpenStreetMapLayerSettings,
TencentMapLayerSettings
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { DeepPartial } from '@shared/models/common';
import { mergeDeep } from '@core/utils';

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts

@ -32,7 +32,7 @@ import { WidgetContext } from '@home/models/widget-component.models';
import { Observable } from 'rxjs';
import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { MapSetting } from '@home/components/widget/lib/maps/map.models';
import { MapSetting } from '@home/components/widget/lib/maps/models/map.models';
import { WidgetComponent } from '@home/components/widget/widget.component';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/map.models';
import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/models/map.models';
import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models';
import { mergeDeep } from '@core/utils';

11
ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss

@ -101,6 +101,17 @@
border: none;
}
}
.tb-cluster-marker-container {
border: none;
background-color: transparent;
}
.tb-cluster-marker-element {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
}
}
.tb-map-sidebar {
.tb-layers, .tb-groups {

26
ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts

@ -22,7 +22,8 @@ import {
defaultImageMapSettings,
GeoMapSettings,
ImageMapSettings,
latLngPointToBounds, MapActionHandler,
latLngPointToBounds,
MapActionHandler,
MapSetting,
MapType,
MapZoomAction,
@ -30,26 +31,23 @@ import {
parseCenterPosition,
TbCircleData,
TbMapDatasource
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils';
import { DeepPartial } from '@shared/models/common';
import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, PointExpression, Projection } from 'leaflet';
import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, Projection } from 'leaflet';
import { forkJoin, Observable, of } from 'rxjs';
import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer';
import { map, switchMap, tap } from 'rxjs/operators';
import '@home/components/widget/lib/maps/leaflet/leaflet-tb';
import {
MapDataLayerType,
TbCirclesDataLayer,
TbMapDataLayer,
TbMarkersDataLayer,
TbPolygonsDataLayer
} from '@home/components/widget/lib/maps/map-data-layer';
import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models';
import { Datasource, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models';
import { WidgetActionDescriptor, widgetType } from '@shared/models/widget.models';
import { EntityDataPageLink } from '@shared/models/query/query.models';
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer';
import { TbPolygonsDataLayer } from '@home/components/widget/lib/maps/data-layer/polygons-data-layer';
import { TbCirclesDataLayer } from '@home/components/widget/lib/maps/data-layer/circles-data-layer';
import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
export abstract class TbMap<S extends BaseMapSettings> {
@ -277,7 +275,7 @@ export abstract class TbMap<S extends BaseMapSettings> {
side: ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left',
distance: 2,
trackOrigin: true,
functionBefore: (instance, helper) => {
functionBefore: (_instance, helper) => {
if (helper.origin.ariaDisabled === 'true' || helper.origin.parentElement.classList.contains('active')) {
return false;
}
@ -478,7 +476,9 @@ class TbGeoMap extends TbMap<GeoMapSettings> {
const map = L.map(this.mapElement, {
scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll),
doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick),
zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons)
zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons),
zoom: this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL,
center: this.defaultCenterPosition
}).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL);
return of(map);
}

34
ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts

@ -24,7 +24,7 @@ import { TbFunction } from '@shared/models/js-function.models';
import { Observable, Observer, of, switchMap } from 'rxjs';
import { map } from 'rxjs/operators';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { MarkerShape } from '@home/components/widget/lib/maps/marker-shape.models';
import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models';
export enum MapType {
geoMap = 'geoMap',
@ -211,6 +211,20 @@ export interface MarkerShapeSettings extends BaseMarkerShapeSettings {
export interface MarkerIconSettings extends BaseMarkerShapeSettings {
icon: string;
}
export interface MarkerClusteringSettings {
enable: boolean;
zoomOnClick: boolean;
maxZoom: number;
maxClusterRadius: number;
zoomAnimation: boolean;
showCoverageOnHover: boolean;
spiderfyOnMaxZoom: boolean;
chunkedLoad: boolean;
lazyLoad: boolean;
useClusterMarkerColorFunction: boolean;
clusterMarkerColorFunction: TbFunction;
}
export interface MarkersDataLayerSettings extends MapDataLayerSettings {
xKey: DataKey;
@ -221,6 +235,7 @@ export interface MarkersDataLayerSettings extends MapDataLayerSettings {
markerImage?: MarkerImageSettings;
markerOffsetX: number;
markerOffsetY: number;
markerClustering: MarkerClusteringSettings;
}
const defaultMarkerLatitudeFunction = 'var value = prevValue || 15.833293;\n' +
@ -292,7 +307,20 @@ export const defaultBaseMarkersDataLayerSettings: Partial<MarkersDataLayerSettin
imageSize: 34
},
markerOffsetX: 0.5,
markerOffsetY: 1
markerOffsetY: 1,
markerClustering: {
enable: false,
zoomOnClick: true,
maxZoom: null,
maxClusterRadius: 80,
zoomAnimation: true,
showCoverageOnHover: true,
spiderfyOnMaxZoom: false,
chunkedLoad: false,
lazyLoad: true,
useClusterMarkerColorFunction: false,
clusterMarkerColorFunction: null
}
} as MarkersDataLayerSettings, defaultBaseDataLayerSettings);
export interface ShapeDataLayerSettings extends MapDataLayerSettings {
@ -744,6 +772,8 @@ export type MapStringFunction = (data: FormattedData<TbMapDatasource>,
export type MarkerImageFunction = (data: FormattedData<TbMapDatasource>, markerImages: string[],
dsData: FormattedData<TbMapDatasource>[]) => MarkerImageInfo;
export type ClusterMarkerColorFunction = (data: FormattedData<TbMapDatasource>[], childCount: number) => string;
export interface TbCircleData {
latitude: number;
longitude: number;

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts

@ -22,7 +22,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { WidgetService } from '@core/http/widget.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/map.models';
import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/models/map.models';
@Component({
selector: 'tb-data-layer-color-settings-panel',

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts

@ -19,7 +19,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ComponentStyle } from '@shared/models/widget-settings.models';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/map.models';
import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/models/map.models';
import {
DataLayerColorSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component';

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts

@ -32,7 +32,7 @@ import {
DataLayerPatternSettings,
DataLayerPatternType,
DataLayerTooltipSettings, dataLayerTooltipTriggers, dataLayerTooltipTriggerTranslationMap
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({

4
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html

@ -251,6 +251,10 @@
formControlName="groups">
</tb-string-items-list>
</div>
<tb-marker-clustering-settings
*ngIf="dataLayerType === 'markers'"
formControlName="markerClustering">
</tb-marker-clustering-settings>
</div>
<div mat-dialog-actions class="flex items-center justify-between">
<button mat-button color="primary"

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts

@ -24,7 +24,7 @@ import {
MarkersDataLayerSettings,
MarkerType,
PolygonsDataLayerSettings, ShapeDataLayerSettings
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
@ -119,6 +119,7 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
this.dataLayerFormGroup.addControl('markerImage', this.fb.control(markersDataLayer.markerImage, Validators.required));
this.dataLayerFormGroup.addControl('markerOffsetX', this.fb.control(markersDataLayer.markerOffsetX));
this.dataLayerFormGroup.addControl('markerOffsetY', this.fb.control(markersDataLayer.markerOffsetY));
this.dataLayerFormGroup.addControl('markerClustering', this.fb.control(markersDataLayer.markerClustering));
this.dataLayerFormGroup.get('markerType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() =>

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts

@ -41,7 +41,7 @@ import {
MapType,
MarkersDataLayerSettings,
PolygonsDataLayerSettings
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models';
import { EntityType } from '@shared/models/entity-type.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts

@ -35,7 +35,7 @@ import {
mapDataLayerValid,
mapDataLayerValidator,
MapType
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component';
import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models';

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts

@ -53,7 +53,7 @@ import {
openStreetMapLayerTranslationMap,
tencentLayerTranslationMap,
tencentLayerTypes
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
import { deepClone } from '@core/utils';
import {
MapLayerSettingsPanelComponent

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts

@ -33,7 +33,7 @@ import {
openStreetMapLayerTranslationMap,
tencentLayerTranslationMap,
tencentLayerTypes
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
import { TranslateService } from '@ngx-translate/core';
@Component({

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts

@ -35,7 +35,7 @@ import {
mapLayerValid,
mapLayerValidator,
MapProvider
} from '@home/components/widget/lib/maps/map.models';
} from '@home/components/widget/lib/maps/models/map.models';
@Component({
selector: 'tb-map-layers',

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts

@ -24,7 +24,7 @@ import {
UntypedFormGroup,
Validator
} from '@angular/forms';
import { ImageSourceType, MapDataLayerType, MapSetting, MapType } from '@home/components/widget/lib/maps/map.models';
import { ImageSourceType, MapDataLayerType, MapSetting, MapType } from '@home/components/widget/lib/maps/models/map.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { merge, Observable } from 'rxjs';
import { coerceBoolean } from '@shared/decorators/coercion';

98
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.html

@ -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>

156
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts

@ -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);
}
}

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts

@ -22,7 +22,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { WidgetService } from '@core/http/widget.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MarkerImageSettings, MarkerImageType } from '@home/components/widget/lib/maps/map.models';
import { MarkerImageSettings, MarkerImageType } from '@home/components/widget/lib/maps/models/map.models';
@Component({
selector: 'tb-marker-image-settings-panel',

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts

@ -18,7 +18,7 @@ import { ChangeDetectorRef, Component, forwardRef, Input, Renderer2, ViewContain
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { MarkerImageSettings, MarkerImageType } from '@home/components/widget/lib/maps/map.models';
import { MarkerImageSettings, MarkerImageType } from '@home/components/widget/lib/maps/models/map.models';
import {
MarkerImageSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/marker-image-settings-panel.component';

4
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts

@ -33,7 +33,7 @@ import {
} from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { MarkerIconSettings, MarkerShapeSettings, MarkerType } from '@home/components/widget/lib/maps/map.models';
import { MarkerIconSettings, MarkerShapeSettings, MarkerType } from '@home/components/widget/lib/maps/models/map.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { DomSanitizer, SafeHtml, SafeUrl } from '@angular/platform-browser';
@ -41,7 +41,7 @@ import { MatIconRegistry } from '@angular/material/icon';
import {
createColorMarkerIconElement,
createColorMarkerShapeURI
} from '@home/components/widget/lib/maps/marker-shape.models';
} from '@home/components/widget/lib/maps/models/marker-shape.models';
import tinycolor from 'tinycolor2';
import { map, share } from 'rxjs/operators';
import { MarkerShapesComponent } from '@home/components/widget/lib/settings/common/map/marker-shapes.component';

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts

@ -19,7 +19,7 @@ import { PageComponent } from '@shared/components/page.component';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { createColorMarkerShapeURI, MarkerShape } from '@home/components/widget/lib/maps/marker-shape.models';
import { createColorMarkerShapeURI, MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models';
import { Observable } from 'rxjs';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { MatIconRegistry } from '@angular/material/icon';

4
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts

@ -223,6 +223,9 @@ import {
MarkerShapeSettingsComponent
} from '@home/components/widget/lib/settings/common/map/marker-shape-settings.component';
import { MarkerShapesComponent } from '@home/components/widget/lib/settings/common/map/marker-shapes.component';
import {
MarkerClusteringSettingsComponent
} from '@home/components/widget/lib/settings/common/map/marker-clustering-settings.component';
@NgModule({
declarations: [
@ -303,6 +306,7 @@ import { MarkerShapesComponent } from '@home/components/widget/lib/settings/comm
MarkerShapesComponent,
MarkerImageSettingsComponent,
MarkerImageSettingsPanelComponent,
MarkerClusteringSettingsComponent,
MapDataLayerDialogComponent,
MapDataLayerRowComponent,
MapDataLayersComponent,

16
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -6926,7 +6926,21 @@
"marker-images": "Marker images",
"marker-offset": "Marker offset",
"offset-horizontal": "Horizontal",
"offset-vertical": "Vertical"
"offset-vertical": "Vertical",
"clustering": {
"use-map-markers-clustering": "Use map markers clustering",
"zoom-on-cluster-click": "Zoom when clicking on a cluster",
"max-zoom": "The maximum zoom level when a marker can be part of a cluster (0 - 18)",
"max-radius": "Maximum radius that a cluster will cover",
"zoom-animation": "Animation on markers when zooming",
"bounds-on-cluster-mouse-over": "Bounds of markers when mouse over a cluster",
"spiderfy-max-zoom-level": "Spiderfy at the max zoom level (to see all cluster markers)",
"load-optimization": "Load optimization",
"chunked-load": "Use chunks for adding markers so that the page does not freeze",
"lazy-load": "Use lazy load for adding markers",
"use-cluster-marker-color-function": "Use cluster markers color function",
"marker-color-function": "Marker color function"
}
},
"polygon": {
"polygon-key": "Polygon key",

4
ui-ngx/src/typings/leaflet-extend-tb.d.ts

@ -16,12 +16,12 @@
import { FormattedData } from '@shared/models/widget.models';
import L from 'leaflet';
import { TileLayerOptions } from 'leaflet';
import { TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models';
// redeclare module, maintains compatibility with @types/leaflet
declare module 'leaflet' {
interface MarkerOptions {
tbMarkerData?: FormattedData;
tbMarkerData?: FormattedData<TbMapDatasource>;
}
interface TileLayer {

Loading…
Cancel
Save