UI: Added clustering markers fn for maps

This commit is contained in:
Artem Dzhereleiko 2022-09-20 18:05:05 +03:00
parent 6445fec439
commit 95b6700296
11 changed files with 226 additions and 47 deletions

View File

@ -21,11 +21,15 @@ import { MarkerClusterGroup, MarkerClusterGroupOptions } from 'leaflet.markerclu
import '@geoman-io/leaflet-geoman-free'; import '@geoman-io/leaflet-geoman-free';
import { import {
CircleData, defaultMapSettings, CircleData,
MarkerClusteringSettings, defaultMapSettings,
MarkerIconInfo, MarkerIconInfo,
MarkerImageInfo, MarkerImageInfo,
WidgetPolygonSettings, WidgetPolylineSettings, WidgetMarkersSettings, WidgetUnitedMapSettings WidgetMarkerClusteringSettings,
WidgetMarkersSettings,
WidgetPolygonSettings,
WidgetPolylineSettings,
WidgetUnitedMapSettings
} from './map-models'; } from './map-models';
import { Marker } from './markers'; import { Marker } from './markers';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
@ -33,10 +37,7 @@ import { Polyline } from './polyline';
import { Polygon } from './polygon'; import { Polygon } from './polygon';
import { Circle } from './circle'; import { Circle } from './circle';
import { createTooltip, isCutPolygon, isJSON } from '@home/components/widget/lib/maps/maps-utils'; import { createTooltip, isCutPolygon, isJSON } from '@home/components/widget/lib/maps/maps-utils';
import { import { checkLngLat, createLoadingDiv } from '@home/components/widget/lib/maps/common-maps-utils';
checkLngLat,
createLoadingDiv
} from '@home/components/widget/lib/maps/common-maps-utils';
import { WidgetContext } from '@home/models/widget-component.models'; import { WidgetContext } from '@home/models/widget-component.models';
import { import {
deepClone, deepClone,
@ -44,7 +45,9 @@ import {
formattedDataFormDatasourceData, formattedDataFormDatasourceData,
isDefinedAndNotNull, isDefinedAndNotNull,
isNotEmptyStr, isNotEmptyStr,
isString, mergeFormattedData, safeExecute isString,
mergeFormattedData,
safeExecute
} from '@core/utils'; } from '@core/utils';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
@ -52,8 +55,8 @@ import {
SelectEntityDialogData SelectEntityDialogData
} from '@home/components/widget/lib/maps/dialogs/select-entity-dialog.component'; } from '@home/components/widget/lib/maps/dialogs/select-entity-dialog.component';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
import { FormattedData, ReplaceInfo } from '@shared/models/widget.models'; import { FormattedData, ReplaceInfo } from '@shared/models/widget.models';
import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
export default abstract class LeafletMap { export default abstract class LeafletMap {
@ -98,6 +101,8 @@ export default abstract class LeafletMap {
translateService: TranslateService; translateService: TranslateService;
tooltipInstances: ITooltipsterInstance[] = []; tooltipInstances: ITooltipsterInstance[] = [];
clusteringSettings: MarkerClusterGroupOptions;
protected constructor(public ctx: WidgetContext, protected constructor(public ctx: WidgetContext,
public $container: HTMLElement, public $container: HTMLElement,
options: WidgetUnitedMapSettings) { options: WidgetUnitedMapSettings) {
@ -111,13 +116,13 @@ export default abstract class LeafletMap {
} }
private initMarkerClusterSettings() { private initMarkerClusterSettings() {
const markerClusteringSettings: MarkerClusteringSettings = this.options; const markerClusteringSettings: WidgetMarkerClusteringSettings = this.options;
if (markerClusteringSettings.useClusterMarkers) { if (markerClusteringSettings.useClusterMarkers) {
// disabled marker cluster icon // disabled marker cluster icon
(L as any).MarkerCluster = (L as any).MarkerCluster.extend({ (L as any).MarkerCluster = (L as any).MarkerCluster.extend({
options: { pmIgnore: true, ...L.Icon.prototype.options } options: { pmIgnore: true, ...L.Icon.prototype.options }
}); });
const clusteringSettings: MarkerClusterGroupOptions = { this.clusteringSettings = {
spiderfyOnMaxZoom: markerClusteringSettings.spiderfyOnMaxZoom, spiderfyOnMaxZoom: markerClusteringSettings.spiderfyOnMaxZoom,
zoomToBoundsOnClick: markerClusteringSettings.zoomOnClick, zoomToBoundsOnClick: markerClusteringSettings.zoomOnClick,
showCoverageOnHover: markerClusteringSettings.showCoverageOnHover, showCoverageOnHover: markerClusteringSettings.showCoverageOnHover,
@ -132,13 +137,45 @@ export default abstract class LeafletMap {
pmIgnore: true pmIgnore: true
} }
}; };
if (markerClusteringSettings.useIconCreateFunction && markerClusteringSettings.clusterMarkerFunction) {
this.clusteringSettings.iconCreateFunction = (cluster) => {
const childCount = cluster.getChildCount();
const markerColor = markerClusteringSettings.clusterMarkerFunction
? safeExecute(markerClusteringSettings.parsedClusterMarkerFunction,
[cluster.getAllChildMarkers(), 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 (markerClusteringSettings.maxClusterRadius && markerClusteringSettings.maxClusterRadius > 0) { if (markerClusteringSettings.maxClusterRadius && markerClusteringSettings.maxClusterRadius > 0) {
clusteringSettings.maxClusterRadius = Math.floor(markerClusteringSettings.maxClusterRadius); this.clusteringSettings.maxClusterRadius = Math.floor(markerClusteringSettings.maxClusterRadius);
} }
if (markerClusteringSettings.maxZoom && markerClusteringSettings.maxZoom >= 0 && markerClusteringSettings.maxZoom < 19) { if (markerClusteringSettings.maxZoom && markerClusteringSettings.maxZoom >= 0 && markerClusteringSettings.maxZoom < 19) {
clusteringSettings.disableClusteringAtZoom = Math.floor(markerClusteringSettings.maxZoom); this.clusteringSettings.disableClusteringAtZoom = Math.floor(markerClusteringSettings.maxZoom);
} }
this.markersCluster = new MarkerClusterGroup(clusteringSettings); this.markersCluster = new MarkerClusterGroup(this.clusteringSettings);
} }
} }
@ -886,6 +923,7 @@ export default abstract class LeafletMap {
if (settings.showTooltip) { if (settings.showTooltip) {
marker.updateMarkerTooltip(data); marker.updateMarkerTooltip(data);
} }
marker.updateMarkerData(data);
marker.updateMarkerIcon(settings); marker.updateMarkerIcon(settings);
return marker; return marker;
} }

View File

@ -17,7 +17,6 @@
import { Datasource, FormattedData } from '@app/shared/models/widget.models'; import { Datasource, FormattedData } from '@app/shared/models/widget.models';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { BaseIconOptions, Icon } from 'leaflet'; import { BaseIconOptions, Icon } from 'leaflet';
import { DeviceProfileType } from '@shared/models/device.models';
export const DEFAULT_MAP_PAGE_SIZE = 16384; export const DEFAULT_MAP_PAGE_SIZE = 16384;
export const DEFAULT_ZOOM_LEVEL = 8; export const DEFAULT_ZOOM_LEVEL = 8;
@ -602,6 +601,12 @@ export interface MarkerClusteringSettings {
showCoverageOnHover: boolean; showCoverageOnHover: boolean;
chunkedLoading: boolean; chunkedLoading: boolean;
removeOutsideVisibleBounds: boolean; removeOutsideVisibleBounds: boolean;
useIconCreateFunction: boolean;
clusterMarkerFunction?: string;
}
export interface WidgetMarkerClusteringSettings extends MarkerClusteringSettings {
parsedClusterMarkerFunction?: GenericFunction;
} }
export const defaultMarkerClusteringSettings: MarkerClusteringSettings = { export const defaultMarkerClusteringSettings: MarkerClusteringSettings = {
@ -613,7 +618,9 @@ export const defaultMarkerClusteringSettings: MarkerClusteringSettings = {
spiderfyOnMaxZoom: false, spiderfyOnMaxZoom: false,
showCoverageOnHover: true, showCoverageOnHover: true,
chunkedLoading: false, chunkedLoading: false,
removeOutsideVisibleBounds: true removeOutsideVisibleBounds: true,
useIconCreateFunction: false,
clusterMarkerFunction: null
}; };
export interface MapEditorSettings { export interface MapEditorSettings {
@ -635,7 +642,7 @@ export const defaultMapEditorSettings: MapEditorSettings = {
}; };
export type UnitedMapSettings = MapProviderSettings & CommonMapSettings & MarkersSettings & export type UnitedMapSettings = MapProviderSettings & CommonMapSettings & MarkersSettings &
PolygonSettings & CircleSettings & PolylineSettings & PointsSettings & MarkerClusteringSettings & MapEditorSettings; PolygonSettings & CircleSettings & PolylineSettings & PointsSettings & WidgetMarkerClusteringSettings & MapEditorSettings;
export const defaultMapSettings: UnitedMapSettings = { export const defaultMapSettings: UnitedMapSettings = {
...defaultMapProviderSettings, ...defaultMapProviderSettings,

View File

@ -250,6 +250,7 @@ export class MapWidgetController implements MapWidgetInterface {
parsedCircleFillColorFunction: parseFunction(settings.circleFillColorFunction, functionParams), parsedCircleFillColorFunction: parseFunction(settings.circleFillColorFunction, functionParams),
parsedCircleTooltipFunction: parseFunction(settings.circleTooltipFunction, functionParams), parsedCircleTooltipFunction: parseFunction(settings.circleTooltipFunction, functionParams),
parsedMarkerImageFunction: parseFunction(settings.markerImageFunction, ['data', 'images', 'dsData', 'dsIndex']), parsedMarkerImageFunction: parseFunction(settings.markerImageFunction, ['data', 'images', 'dsData', 'dsIndex']),
parsedClusterMarkerFunction: parseFunction(settings.clusterMarkerFunction, ['data', 'childCount']),
// labelColor: this.ctx.widgetConfig.color, // labelColor: this.ctx.widgetConfig.color,
// polygonLabelColor: this.ctx.widgetConfig.color, // polygonLabelColor: this.ctx.widgetConfig.color,
polygonKeyName: (settings as any).polKeyName ? (settings as any).polKeyName : settings.polygonKeyName, polygonKeyName: (settings as any).polKeyName ? (settings as any).polKeyName : settings.polygonKeyName,

View File

@ -41,3 +41,15 @@
.leaflet-container { .leaflet-container {
background-color: white; background-color: white;
} }
.tb-cluster-marker-container {
border: none;
background-color: transparent;
}
.tb-cluster-marker-element {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
}

View File

@ -15,11 +15,7 @@
/// ///
import L, { LeafletMouseEvent } from 'leaflet'; import L, { LeafletMouseEvent } from 'leaflet';
import { import { MarkerIconInfo, MarkerIconReadyFunction, MarkerImageInfo, WidgetMarkersSettings, } from './map-models';
MarkerIconInfo,
MarkerIconReadyFunction,
MarkerImageInfo, WidgetMarkersSettings,
} from './map-models';
import { bindPopupActions, createTooltip } from './maps-utils'; import { bindPopupActions, createTooltip } from './maps-utils';
import { aspectCache, parseWithTranslation } from './common-maps-utils'; import { aspectCache, parseWithTranslation } from './common-maps-utils';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
@ -46,7 +42,8 @@ export class Marker {
snappable = false) { snappable = false) {
this.leafletMarker = L.marker(location, { this.leafletMarker = L.marker(location, {
pmIgnore: !settings.draggableMarker, pmIgnore: !settings.draggableMarker,
snapIgnore: !snappable snapIgnore: !snappable,
tbMarkerData: this.data
}); });
this.markerOffset = [ this.markerOffset = [
@ -110,6 +107,10 @@ export class Marker {
} }
} }
updateMarkerData(data: FormattedData) {
this.leafletMarker.options.tbMarkerData = data;
}
updateMarkerPosition(position: L.LatLng) { updateMarkerPosition(position: L.LatLng) {
if (!this.leafletMarker.getLatLng().equals(position) && !this.editing) { if (!this.leafletMarker.getLatLng().equals(position) && !this.editing) {
this.location = position; this.location = position;

View File

@ -64,6 +64,30 @@
{{ 'widgets.maps.cluster-markers-lazy-load' | translate }} {{ 'widgets.maps.cluster-markers-lazy-load' | translate }}
</mat-slide-toggle> </mat-slide-toggle>
</fieldset> </fieldset>
<fieldset class="fields-group fields-group-slider">
<legend class="group-title" translate>widgets.maps.clustering-markers</legend>
<mat-expansion-panel class="tb-settings" [expanded]="markerClusteringSettingsFormGroup.get('useIconCreateFunction').value">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<mat-slide-toggle formControlName="useIconCreateFunction" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'widgets.maps.use-icon-create-function' | translate }}
</mat-slide-toggle>
</mat-panel-title>
<mat-panel-description fxLayoutAlign="end center" fxHide.xs translate>
widget-config.advanced-settings
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-js-func formControlName="clusterMarkerFunction"
[globalVariables]="functionScopeVariables"
[functionArgs]="['data', 'childCount']"
functionTitle="{{ 'widgets.maps.marker-color-function' | translate }}"
helpId="widget/lib/map/clustering_color_fn">
</tb-js-func>
</ng-template>
</mat-expansion-panel>
</fieldset>
</ng-template> </ng-template>
</mat-expansion-panel> </mat-expansion-panel>
</fieldset> </fieldset>

View File

@ -56,6 +56,8 @@ export class MarkerClusteringSettingsComponent extends PageComponent implements
private modelValue: MarkerClusteringSettings; private modelValue: MarkerClusteringSettings;
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
private propagateChange = null; private propagateChange = null;
public markerClusteringSettingsFormGroup: FormGroup; public markerClusteringSettingsFormGroup: FormGroup;
@ -77,7 +79,9 @@ export class MarkerClusteringSettingsComponent extends PageComponent implements
spiderfyOnMaxZoom: [null, []], spiderfyOnMaxZoom: [null, []],
showCoverageOnHover: [null, []], showCoverageOnHover: [null, []],
chunkedLoading: [null, []], chunkedLoading: [null, []],
removeOutsideVisibleBounds: [null, []] removeOutsideVisibleBounds: [null, []],
useIconCreateFunction: [null, []],
clusterMarkerFunction: [null, []]
}); });
this.markerClusteringSettingsFormGroup.valueChanges.subscribe(() => { this.markerClusteringSettingsFormGroup.valueChanges.subscribe(() => {
this.updateModel(); this.updateModel();

View File

@ -0,0 +1,64 @@
#### Clustering marker function
<div class="divider"></div>
<br/>
*function (data, childCount): string*
A JavaScript function used to compute clustering marker color.
**Parameters:**
<ul>
<li><b>data:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts#L108" target="_blank">FormattedData</a></code> - A <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts#L108" target="_blank">FormattedData</a> object associated with marker or data point of the route.<br/>
Represents basic entity properties (ex. <code>entityId</code>, <code>entityName</code>)<br/>and provides access to other entity attributes/timeseries declared in widget datasource configuration.
</li>
<li><b>childCount:</b> <code>number</code> - number of markers in this cluster
</li>
</ul>
**Returns:**
Should return string value presenting color of the marker.
In case no data is returned, color value from **Color** settings field will be used.
<div class="divider"></div>
##### Examples
<ul>
<li>
Calculate color depending on temperature telemetry value:
</li>
```javascript
let customColor;
for (let markerData of data) {
if (markerData.options.tbMarkerData.temperature > 40) {
customColor = 'red'
}
}
return customColor ? customColor : 'green';
{:copy-code}
```
<li>
Calculate color depending on childCount:
</li>
```javascript
if (childCount < 10) {
return 'green';
} else if (childCount < 100) {
return 'yellow';
} else {
return 'red';
}
{:copy-code}
```
</ul>
<br>
<br>

View File

@ -4543,7 +4543,10 @@
"point-color-function": "Point color function", "point-color-function": "Point color function",
"use-point-as-anchor": "Use point as anchor", "use-point-as-anchor": "Use point as anchor",
"point-as-anchor-function": "Point as anchor function", "point-as-anchor-function": "Point as anchor function",
"independent-point-tooltip": "Independent point tooltip" "independent-point-tooltip": "Independent point tooltip",
"clustering-markers": "Clustering markers",
"use-icon-create-function": "Use markers colour function",
"marker-color-function": "Marker color function"
}, },
"markdown": { "markdown": {
"use-markdown-text-function": "Use markdown/HTML value function", "use-markdown-text-function": "Use markdown/HTML value function",

View File

@ -0,0 +1,24 @@
///
/// Copyright © 2016-2022 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 { FormattedData } from '@shared/models/widget.models';
// redeclare module, maintains compatibility with @types/leaflet
declare module 'leaflet' {
interface MarkerOptions {
tbMarkerData?: FormattedData;
}
}

View File

@ -23,7 +23,8 @@
"src/typings/jquery.flot.typings.d.ts", "src/typings/jquery.flot.typings.d.ts",
"src/typings/jquery.jstree.typings.d.ts", "src/typings/jquery.jstree.typings.d.ts",
"src/typings/split.js.typings.d.ts", "src/typings/split.js.typings.d.ts",
"src/typings/leaflet-geoman-extend.d.ts" "src/typings/leaflet-geoman-extend.d.ts",
"src/typings/leaflet-extend-tb.d.ts",
], ],
"paths": { "paths": {
"@app/*": ["src/app/*"], "@app/*": ["src/app/*"],