57 changed files with 1609 additions and 421 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,466 @@ |
|||
///
|
|||
/// Copyright © 2016-2025 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 { |
|||
defaultBasePolylinesDataLayerSettings, |
|||
isCutPolygon, |
|||
isJSON, |
|||
MapDataLayerType, |
|||
PolylinesDataLayerSettings, |
|||
TbMapDatasource, |
|||
TbPolylineCoordinates, |
|||
TbPolylineData, |
|||
TbPolylineRawCoordinates |
|||
} from '@shared/models/widget/maps/map.models'; |
|||
import L from 'leaflet'; |
|||
import { DataKey, FormattedData } from '@shared/models/widget.models'; |
|||
import { ShapeStyleInfo, 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 { |
|||
TbLatestDataLayerItem, |
|||
UnplacedMapDataItem |
|||
} from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; |
|||
import { map } from 'rxjs/operators'; |
|||
|
|||
class TbPolylineDataLayerItem extends TbLatestDataLayerItem<PolylinesDataLayerSettings, TbPolylineDataLayer> { |
|||
|
|||
private polylineContainer: L.FeatureGroup; |
|||
private polyline: L.Polyline; |
|||
private polylineStyleInfo: ShapeStyleInfo; |
|||
private editing = false; |
|||
|
|||
constructor(data: FormattedData<TbMapDatasource>, |
|||
dsData: FormattedData<TbMapDatasource>[], |
|||
protected settings: PolylinesDataLayerSettings, |
|||
protected dataLayer: TbPolylineDataLayer) { |
|||
super(data, dsData, settings, dataLayer); |
|||
} |
|||
|
|||
public isEditing() { |
|||
return this.editing; |
|||
} |
|||
|
|||
public updateBubblingMouseEvents() { |
|||
this.polyline.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); |
|||
} |
|||
|
|||
public remove() { |
|||
super.remove(); |
|||
if (this.polylineStyleInfo?.patternId) { |
|||
this.dataLayer.getMap().unUseShapePattern(this.polylineStyleInfo.patternId); |
|||
} |
|||
} |
|||
|
|||
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer { |
|||
const polyData = this.dataLayer.extractPolylineCoordinates(data); |
|||
const polyConstructor = L.polyline; |
|||
this.polyline = polyConstructor(polyData as (TbPolylineRawCoordinates & L.LatLngTuple[]), { |
|||
noClip: true, |
|||
snapIgnore: !this.dataLayer.isSnappable(), |
|||
bubblingMouseEvents: !this.dataLayer.isEditMode() |
|||
}); |
|||
|
|||
this.dataLayer.getStrokeStyle(data, dsData, this.polylineStyleInfo?.patternId).subscribe((styleInfo) => { |
|||
this.polylineStyleInfo = styleInfo; |
|||
if (this.polyline) { |
|||
this.polyline.setStyle(this.polylineStyleInfo.style); |
|||
} |
|||
}); |
|||
|
|||
this.polylineContainer = L.featureGroup(); |
|||
this.polyline.addTo(this.polylineContainer); |
|||
|
|||
this.updateLabel(data, dsData); |
|||
return this.polylineContainer; |
|||
} |
|||
|
|||
protected unbindLabel() { |
|||
this.polylineContainer.unbindTooltip(); |
|||
} |
|||
|
|||
protected bindLabel(content: L.Content): void { |
|||
this.polylineContainer.bindTooltip(content, {className: 'tb-polyline-label', permanent: true, direction: 'center'}) |
|||
.openTooltip(this.polylineContainer.getBounds().getCenter()); |
|||
} |
|||
|
|||
protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void { |
|||
this.dataLayer.getStrokeStyle(data, dsData, this.polylineStyleInfo?.patternId).subscribe((styleInfo) => { |
|||
this.polylineStyleInfo = styleInfo; |
|||
this.updatePolylineShape(data); |
|||
this.updateTooltip(data, dsData); |
|||
this.updateLabel(data, dsData); |
|||
|
|||
if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) { |
|||
this.polyline.setStyle(this.polylineStyleInfo.style); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void { |
|||
this.updatePolylineShape(data); |
|||
} |
|||
|
|||
protected addItemClass(clazz: string): void { |
|||
if ((this.polyline as any)._path) { |
|||
L.DomUtil.addClass((this.polyline as any)._path, clazz); |
|||
} |
|||
} |
|||
|
|||
protected removeItemClass(clazz: string): void { |
|||
if ((this.polyline as any)._path) { |
|||
L.DomUtil.removeClass((this.polyline as any)._path, clazz); |
|||
} |
|||
} |
|||
|
|||
protected enableDrag(): void { |
|||
this.polyline.pm.setOptions({ |
|||
snappable: this.dataLayer.isSnappable() |
|||
}); |
|||
this.polyline.pm.enableLayerDrag(); |
|||
this.polyline.on('pm:dragstart', () => { |
|||
this.editing = true; |
|||
}); |
|||
this.polyline.on('pm:drag', () => { |
|||
if (this.tooltip?.isOpen()) { |
|||
this.tooltip.setLatLng(this.polyline.getBounds().getCenter()); |
|||
} |
|||
}); |
|||
this.polyline.on('pm:dragend', () => { |
|||
this.savePolylineCoordinates(); |
|||
this.editing = false; |
|||
}); |
|||
} |
|||
|
|||
protected disableDrag(): void { |
|||
this.polyline.pm.disableLayerDrag(); |
|||
this.polyline.off('pm:dragstart'); |
|||
this.polyline.off('pm:dragend'); |
|||
} |
|||
|
|||
protected onSelected(): L.TB.ToolbarButtonOptions[] { |
|||
const buttons: L.TB.ToolbarButtonOptions[] = []; |
|||
if (this.dataLayer.isEditEnabled()) { |
|||
this.enablePolylineEditMode(); |
|||
buttons.push( |
|||
{ |
|||
id: 'cut', |
|||
title: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.cut'), |
|||
iconClass: 'tb-cut', |
|||
click: (e, button) => { |
|||
const map = this.dataLayer.getMap().getMap(); |
|||
if (!map.pm.globalCutModeEnabled()) { |
|||
this.disablePolylineRotateMode(); |
|||
this.disablePolylineEditMode(); |
|||
this.enablePolylineCutMode(button); |
|||
} else { |
|||
this.disablePolylineCutMode(button); |
|||
this.enablePolylineEditMode(); |
|||
} |
|||
} |
|||
}, |
|||
{ |
|||
id: 'rotate', |
|||
title: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polyline.rotate'), |
|||
iconClass: 'tb-rotate', |
|||
click: (e, button) => { |
|||
if (!this.polyline.pm.rotateEnabled()) { |
|||
this.disablePolylineCutMode(); |
|||
this.disablePolylineEditMode(); |
|||
this.enablePolylineRotateMode(button); |
|||
} else { |
|||
this.disablePolylineRotateMode(button); |
|||
this.enablePolylineEditMode(); |
|||
} |
|||
} |
|||
} |
|||
); |
|||
} |
|||
return buttons; |
|||
} |
|||
|
|||
protected onDeselected(): void { |
|||
if (this.dataLayer.isEditEnabled()) { |
|||
this.disablePolylineEditMode(); |
|||
this.disablePolylineRotateMode(); |
|||
} |
|||
} |
|||
|
|||
protected canDeselect(cancel = false): boolean { |
|||
const map = this.dataLayer.getMap().getMap(); |
|||
if (map.pm.globalCutModeEnabled()) { |
|||
if (cancel) { |
|||
this.disablePolylineCutMode(); |
|||
} |
|||
return false; |
|||
} else if (this.polyline.pm.rotateEnabled()) { |
|||
if (cancel) { |
|||
this.disablePolylineRotateMode(); |
|||
} |
|||
return false; |
|||
} else if (this.editing) { |
|||
return false; |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
protected removeDataItemTitle(): string { |
|||
return this.dataLayer.getCtx().translate.instant('widgets.maps.data-layer.polyline.remove-polyline-for', {entityName: this.data.entityName}); |
|||
} |
|||
|
|||
protected removeDataItem(): Observable<any> { |
|||
return this.dataLayer.savePolylineCoordinates(this.data, null); |
|||
} |
|||
|
|||
private enablePolylineEditMode() { |
|||
this.polyline.on('pm:markerdragstart', () => this.editing = true); |
|||
this.polyline.on('pm:markerdragend', () => setTimeout(() => { |
|||
this.editing = false; |
|||
})); |
|||
this.polyline.on('pm:edit', () => this.savePolylineCoordinates()); |
|||
this.polyline.pm.enable(); |
|||
const map = this.dataLayer.getMap(); |
|||
map.getEditToolbar().getButton('remove')?.setDisabled(false); |
|||
} |
|||
|
|||
private disablePolylineEditMode() { |
|||
this.polyline.pm.disable(); |
|||
this.polyline.off('pm:markerdragstart'); |
|||
this.polyline.off('pm:markerdragend'); |
|||
this.polyline.off('pm:edit'); |
|||
const map = this.dataLayer.getMap(); |
|||
map.getEditToolbar().getButton('remove')?.setDisabled(true); |
|||
} |
|||
|
|||
private enablePolylineCutMode(cutButton?: L.TB.ToolbarButton) { |
|||
this.polylineContainer.closePopup(); |
|||
this.editing = true; |
|||
this.polyline.options.bubblingMouseEvents = true; |
|||
this.polyline.setStyle({ |
|||
...this.polylineStyleInfo.style, dashArray: '5 5', weight: 3, |
|||
color: '#3388ff', opacity: 1, fillColor: '#3388ff', fillOpacity: 0.2 |
|||
}); |
|||
this.addItemClass('tb-cut-mode'); |
|||
this.polyline.once('pm:cut', (e) => { |
|||
if (e.layer instanceof L.Polyline) { |
|||
this.polyline = L.polyline(e.layer.getLatLngs() as L.LatLngExpression[] | L.LatLngExpression[][] , { |
|||
...this.polylineStyleInfo.style, |
|||
snapIgnore: !this.dataLayer.isSnappable(), |
|||
bubblingMouseEvents: !this.dataLayer.isEditMode() |
|||
}); |
|||
this.polyline.addTo(this.polylineContainer); |
|||
} else if (e.layer instanceof L.LayerGroup) { |
|||
const parts: L.LatLngExpression[][] = []; |
|||
|
|||
if (e.layer && typeof e.layer.getLayers === 'function') { |
|||
const segments: L.Polyline[] = e.layer.getLayers() as L.Polyline[]; |
|||
segments.forEach(segment => { |
|||
parts.push(segment.getLatLngs() as L.LatLngExpression[]); |
|||
}); |
|||
} else if (e.layer instanceof L.Polyline) { |
|||
parts.push(e.layer.getLatLngs() as L.LatLngExpression[]); |
|||
} |
|||
|
|||
if (parts.length > 0) { |
|||
this.polyline = L.polyline(parts, { |
|||
...this.polylineStyleInfo.style, |
|||
snapIgnore: !this.dataLayer.isSnappable(), |
|||
bubblingMouseEvents: !this.dataLayer.isEditMode() |
|||
}); |
|||
this.polyline.addTo(this.polylineContainer); |
|||
} |
|||
} |
|||
// @ts-ignore
|
|||
e.layer._pmTempLayer = true; |
|||
e.layer.remove(); |
|||
this.polylineContainer.removeLayer(this.polyline); |
|||
// @ts-ignore
|
|||
this.polyline._pmTempLayer = false; |
|||
this.polyline.addTo(this.polylineContainer); |
|||
this.updateSelectedState(); |
|||
cutButton?.setActive(false); |
|||
this.savePolylineCoordinates(); |
|||
}); |
|||
const map = this.dataLayer.getMap().getMap(); |
|||
map.pm.setLang('en', { |
|||
tooltips: { |
|||
firstVertex: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polyline.polyline-place-first-point-cut-hint'), |
|||
finishLine: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polyline.finish-polyline-cut-hint') |
|||
} |
|||
}, 'en'); |
|||
map.pm.enableGlobalCutMode({ |
|||
// @ts-ignore
|
|||
layersToCut: [this.polyline] |
|||
}); |
|||
// @ts-ignore
|
|||
L.DomUtil.addClass(map.pm.Draw.Cut._hintMarker.getTooltip()._container, 'tb-place-item-label'); |
|||
cutButton?.setActive(true); |
|||
map.once('pm:globalcutmodetoggled', (e) => { |
|||
if (!e.enabled) { |
|||
this.disablePolylineCutMode(cutButton); |
|||
this.enablePolylineEditMode(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private disablePolylineCutMode(cutButton?: L.TB.ToolbarButton) { |
|||
this.editing = false; |
|||
this.polyline.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); |
|||
this.polyline.setStyle({...this.polylineStyleInfo.style, dashArray: null}); |
|||
this.removeItemClass('tb-cut-mode'); |
|||
this.polyline.off('pm:cut'); |
|||
const map = this.dataLayer.getMap().getMap(); |
|||
map.pm.disableGlobalCutMode(); |
|||
cutButton?.setActive(false); |
|||
} |
|||
|
|||
private enablePolylineRotateMode(rotateButton?: L.TB.ToolbarButton) { |
|||
this.polylineContainer.closePopup(); |
|||
this.editing = true; |
|||
this.polyline.on('pm:rotateend', () => { |
|||
this.savePolylineCoordinates(); |
|||
}); |
|||
this.polyline.pm.enableRotate(); |
|||
rotateButton?.setActive(true); |
|||
this.polyline.on('pm:rotatedisable', () => { |
|||
this.disablePolylineRotateMode(rotateButton); |
|||
this.enablePolylineEditMode(); |
|||
}); |
|||
} |
|||
|
|||
private disablePolylineRotateMode(rotateButton?: L.TB.ToolbarButton) { |
|||
this.editing = false; |
|||
this.polyline.pm.disableRotate(); |
|||
this.polyline.off('pm:rotateend'); |
|||
this.polyline.off('pm:rotatedisable'); |
|||
rotateButton?.setActive(false); |
|||
} |
|||
|
|||
private savePolylineCoordinates() { |
|||
let coordinates: TbPolylineCoordinates = this.polyline.getLatLngs(); |
|||
if (coordinates.length === 1) { |
|||
coordinates = coordinates[0] as TbPolylineCoordinates; |
|||
} |
|||
if (this.polyline instanceof L.Rectangle && !isCutPolygon(coordinates)) { |
|||
const bounds = this.polyline.getBounds(); |
|||
const boundsArray = [bounds.getNorthWest(), bounds.getNorthEast(), bounds.getSouthWest(), bounds.getSouthEast()]; |
|||
if (coordinates.every(point => boundsArray.find(boundPoint => boundPoint.equals(point as L.LatLng)) !== undefined)) { |
|||
coordinates = [bounds.getNorthWest(), bounds.getSouthEast()]; |
|||
} |
|||
} |
|||
this.dataLayer.savePolylineCoordinates(this.data, coordinates).subscribe(); |
|||
} |
|||
|
|||
private updatePolylineShape(data: FormattedData<TbMapDatasource>) { |
|||
if (this.editing) { |
|||
return; |
|||
} |
|||
|
|||
const polyData = this.dataLayer.extractPolylineCoordinates(data) as TbPolylineData; |
|||
if (this.polyline instanceof L.Polyline && !(this.polyline instanceof L.Rectangle)) { |
|||
this.polyline.setLatLngs(polyData as L.LatLngExpression[] | L.LatLngExpression[][]); |
|||
} else if (this.polyline instanceof L.Rectangle && polyData.length === 2) { |
|||
const bounds = new L.LatLngBounds(polyData as L.LatLngTuple[]); |
|||
this.polyline.setBounds(bounds); |
|||
} else { |
|||
this.polylineContainer.removeLayer(this.polyline); |
|||
if (polyData.length === 2 && isCutPolygon(polyData)) { |
|||
this.polyline = L.polyline(polyData as L.LatLngExpression[] | L.LatLngExpression[][], { |
|||
...this.polylineStyleInfo.style, |
|||
snapIgnore: !this.dataLayer.isSnappable(), |
|||
bubblingMouseEvents: !this.dataLayer.isEditMode(), |
|||
noClip: true |
|||
}); |
|||
} |
|||
|
|||
this.polyline.addTo(this.polylineContainer); |
|||
this.editModeUpdated(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
export class TbPolylineDataLayer extends TbShapesDataLayer<PolylinesDataLayerSettings, TbPolylineDataLayer> { |
|||
|
|||
constructor(protected map: TbMap<any>, |
|||
inputSettings: PolylinesDataLayerSettings) { |
|||
super(map, inputSettings); |
|||
} |
|||
|
|||
public dataLayerType(): MapDataLayerType { |
|||
return 'polylines'; |
|||
} |
|||
|
|||
public placeItem(item: UnplacedMapDataItem, layer: L.Layer): void { |
|||
if (layer instanceof L.Polyline) { |
|||
let coordinates: TbPolylineCoordinates; |
|||
coordinates = layer.getLatLngs(); |
|||
if (coordinates.length === 1) { |
|||
coordinates = coordinates[0] as TbPolylineCoordinates; |
|||
} |
|||
this.savePolylineCoordinates(item.entity, coordinates).subscribe( |
|||
(converted) => { |
|||
item.entity[this.settings.polylineKey.label] = JSON.stringify(converted); |
|||
this.createItemFromUnplaced(item); |
|||
} |
|||
); |
|||
} else { |
|||
console.warn('Unable to place item, layer is not a polyline.'); |
|||
} |
|||
} |
|||
|
|||
public extractPolylineCoordinates(data: FormattedData<TbMapDatasource>): TbPolylineRawCoordinates { |
|||
let rawPolyData = data[this.settings.polylineKey.label]; |
|||
if (isString(rawPolyData)) { |
|||
rawPolyData = JSON.parse(rawPolyData); |
|||
} |
|||
return this.map.polylineDataToCoordinates(rawPolyData); |
|||
} |
|||
|
|||
public savePolylineCoordinates(data: FormattedData<TbMapDatasource>, coordinates: TbPolylineCoordinates): Observable<TbPolylineRawCoordinates> { |
|||
const converted = coordinates ? this.map.coordinatesToPolylineData(coordinates) : null; |
|||
const polylineData = [ |
|||
{ |
|||
dataKey: this.settings.polylineKey, |
|||
value: converted |
|||
} |
|||
]; |
|||
return this.map.saveItemData(data.$datasource, polylineData, this.settings.edit?.attributeScope).pipe( |
|||
map(() => converted) |
|||
); |
|||
} |
|||
|
|||
protected getDataKeys(): DataKey[] { |
|||
return [this.settings.polylineKey]; |
|||
} |
|||
|
|||
protected defaultBaseSettings(map: TbMap<any>): Partial<PolylinesDataLayerSettings> { |
|||
return defaultBasePolylinesDataLayerSettings(map.type()); |
|||
} |
|||
|
|||
protected doSetup(): Observable<any> { |
|||
return super.doSetup(); |
|||
} |
|||
|
|||
protected isValidLayerData(layerData: FormattedData<TbMapDatasource>): boolean { |
|||
return layerData && ((isNotEmptyStr(layerData[this.settings.polylineKey.label]) && !isJSON(layerData[this.settings.polylineKey.label]) |
|||
|| Array.isArray(layerData[this.settings.polylineKey.label]))); |
|||
} |
|||
|
|||
protected createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbPolylineDataLayerItem { |
|||
return new TbPolylineDataLayerItem(data, dsData, this.settings, this); |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
#### Polyline label function |
|||
|
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
*function (data, dsData): string* |
|||
|
|||
A JavaScript function used to compute text or HTML code of the polyline label. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
{% include widget/lib/map/map_fn_args %} |
|||
</ul> |
|||
|
|||
**Returns:** |
|||
|
|||
Should return string value presenting text or HTML of the polyline label. |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
{% include widget/lib/map/label_fn_examples %} |
|||
@ -0,0 +1,24 @@ |
|||
#### Polyline stroke color function |
|||
|
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
*function (data, dsData): string* |
|||
|
|||
A JavaScript function used to compute stroke color of the polyline. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
{% include widget/lib/map/map_fn_args %} |
|||
</ul> |
|||
|
|||
**Returns:** |
|||
|
|||
Should return string value presenting stroke color of the polyline. |
|||
|
|||
In case no data is returned, color value from **Color** settings field will be used. |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
{% include widget/lib/map/color_fn_examples %} |
|||
@ -0,0 +1,22 @@ |
|||
#### Polyline tooltip function |
|||
|
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
*function (data, dsData): string* |
|||
|
|||
A JavaScript function used to compute text or HTML code to be displayed in the polyline tooltip. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
{% include widget/lib/map/map_fn_args %} |
|||
</ul> |
|||
|
|||
**Returns:** |
|||
|
|||
Should return string value presenting text or HTML for the polyline tooltip. |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
{% include widget/lib/map/tooltip_fn_examples %} |
|||
Loading…
Reference in new issue