Browse Source

UI: New maps - improve polygons/circles fill settings with strip and image patterns.

pull/13126/head
Igor Kulikov 1 year ago
parent
commit
099e3b6951
  1. 2
      ui-ngx/package.json
  2. 35
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts
  3. 48
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts
  4. 319
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts
  5. 433
      ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts
  6. 57
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts
  7. 7
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts
  8. 43
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html
  9. 34
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts
  10. 84
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.html
  11. 54
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.scss
  12. 101
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts
  13. 29
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html
  14. 96
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts
  15. 84
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.html
  16. 80
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.scss
  17. 106
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts
  18. 27
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.html
  19. 148
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts
  20. 16
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  21. 109
      ui-ngx/src/app/shared/models/widget/maps/map.models.ts
  22. 52
      ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn.md
  23. 9
      ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn_args.md
  24. 23
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  25. 115
      ui-ngx/src/typings/leaflet-extend-tb.d.ts
  26. 8
      ui-ngx/yarn.lock

2
ui-ngx/package.json

@ -26,7 +26,7 @@
"@auth0/angular-jwt": "^5.2.0", "@auth0/angular-jwt": "^5.2.0",
"@flowjs/flow.js": "^2.14.1", "@flowjs/flow.js": "^2.14.1",
"@flowjs/ngx-flow": "18.0.1", "@flowjs/ngx-flow": "18.0.1",
"@geoman-io/leaflet-geoman-free": "2.17.0", "@geoman-io/leaflet-geoman-free": "2.18.3",
"@iplab/ngx-color-picker": "^18.0.1", "@iplab/ngx-color-picker": "^18.0.1",
"@mat-datetimepicker/core": "~14.0.0", "@mat-datetimepicker/core": "~14.0.0",
"@mdi/svg": "^7.4.47", "@mdi/svg": "^7.4.47",

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

@ -23,7 +23,7 @@ import {
} from '@shared/models/widget/maps/map.models'; } from '@shared/models/widget/maps/map.models';
import L from 'leaflet'; import L from 'leaflet';
import { DataKey, FormattedData } from '@shared/models/widget.models'; import { DataKey, FormattedData } from '@shared/models/widget.models';
import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; import { ShapeStyleInfo, TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer';
import { TbMap } from '@home/components/widget/lib/maps/map'; import { TbMap } from '@home/components/widget/lib/maps/map';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { isNotEmptyStr } from '@core/utils'; import { isNotEmptyStr } from '@core/utils';
@ -36,7 +36,7 @@ import { map } from 'rxjs/operators';
class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> { class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> {
private circle: L.Circle; private circle: L.Circle;
private circleStyle: L.PathOptions; private circleStyleInfo: ShapeStyleInfo;
private editing = false; private editing = false;
constructor(data: FormattedData<TbMapDatasource>, constructor(data: FormattedData<TbMapDatasource>,
@ -54,16 +54,29 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettin
this.circle.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); this.circle.options.bubblingMouseEvents = !this.dataLayer.isEditMode();
} }
public remove() {
super.remove();
if (this.circleStyleInfo?.patternId) {
this.dataLayer.getMap().unUseShapePattern(this.circleStyleInfo.patternId);
}
}
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer { protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer {
const circleData = this.dataLayer.extractCircleCoordinates(data); const circleData = this.dataLayer.extractCircleCoordinates(data);
const center = new L.LatLng(circleData.latitude, circleData.longitude); const center = new L.LatLng(circleData.latitude, circleData.longitude);
this.circleStyle = this.dataLayer.getShapeStyle(data, dsData);
this.circle = L.circle(center, { this.circle = L.circle(center, {
bubblingMouseEvents: !this.dataLayer.isEditMode(), bubblingMouseEvents: !this.dataLayer.isEditMode(),
radius: circleData.radius, radius: circleData.radius,
...this.circleStyle,
snapIgnore: !this.dataLayer.isSnappable() snapIgnore: !this.dataLayer.isSnappable()
}); });
this.dataLayer.getShapeStyle(data, dsData, this.circleStyleInfo?.patternId).subscribe((styleInfo) => {
this.circleStyleInfo = styleInfo;
if (this.circle) {
this.circle.setStyle(this.circleStyleInfo.style);
}
});
this.updateLabel(data, dsData); this.updateLabel(data, dsData);
return this.circle; return this.circle;
} }
@ -78,11 +91,13 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettin
} }
protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void { protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.circleStyle = this.dataLayer.getShapeStyle(data, dsData); this.dataLayer.getShapeStyle(data, dsData, this.circleStyleInfo?.patternId).subscribe((styleInfo) => {
this.updateCircleShape(data); this.circleStyleInfo = styleInfo;
this.updateTooltip(data, dsData); this.updateCircleShape(data);
this.updateLabel(data, dsData); this.updateTooltip(data, dsData);
this.circle.setStyle(this.circleStyle); this.updateLabel(data, dsData);
this.circle.setStyle(this.circleStyleInfo.style);
});
} }
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void { protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void {
@ -126,7 +141,7 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettin
this.circle.on('pm:markerdragstart', () => this.editing = true); this.circle.on('pm:markerdragstart', () => this.editing = true);
this.circle.on('pm:markerdragend', () => this.editing = false); this.circle.on('pm:markerdragend', () => this.editing = false);
this.circle.on('pm:edit', () => this.saveCircleCoordinates()); this.circle.on('pm:edit', () => this.saveCircleCoordinates());
this.circle.pm.enable(); this.circle.pm.enable({draggable: true, snappable: this.dataLayer.isSnappable()});
} }
return []; return [];
} }

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

@ -22,7 +22,7 @@ import {
} from '@shared/models/widget/maps/map.models'; } from '@shared/models/widget/maps/map.models';
import L from 'leaflet'; import L from 'leaflet';
import { DataKey, FormattedData } from '@shared/models/widget.models'; import { DataKey, FormattedData } from '@shared/models/widget.models';
import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; import { ShapeStyleInfo, TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer';
import { TbMap } from '@home/components/widget/lib/maps/map'; import { TbMap } from '@home/components/widget/lib/maps/map';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { isNotEmptyStr, isString } from '@core/utils'; import { isNotEmptyStr, isString } from '@core/utils';
@ -36,7 +36,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
private polygonContainer: L.FeatureGroup; private polygonContainer: L.FeatureGroup;
private polygon: L.Polygon; private polygon: L.Polygon;
private polygonStyle: L.PathOptions; private polygonStyleInfo: ShapeStyleInfo;
private editing = false; private editing = false;
constructor(data: FormattedData<TbMapDatasource>, constructor(data: FormattedData<TbMapDatasource>,
@ -54,16 +54,29 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
this.polygon.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); this.polygon.options.bubblingMouseEvents = !this.dataLayer.isEditMode();
} }
public remove() {
super.remove();
if (this.polygonStyleInfo?.patternId) {
this.dataLayer.getMap().unUseShapePattern(this.polygonStyleInfo.patternId);
}
}
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer { protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer {
const polyData = this.dataLayer.extractPolygonCoordinates(data); const polyData = this.dataLayer.extractPolygonCoordinates(data);
const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle;
this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData);
this.polygon = polyConstructor(polyData as (TbPolygonRawCoordinates & L.LatLngTuple[]), { this.polygon = polyConstructor(polyData as (TbPolygonRawCoordinates & L.LatLngTuple[]), {
...this.polygonStyle, noClip: true,
snapIgnore: !this.dataLayer.isSnappable(), snapIgnore: !this.dataLayer.isSnappable(),
bubblingMouseEvents: !this.dataLayer.isEditMode() bubblingMouseEvents: !this.dataLayer.isEditMode()
}); });
this.dataLayer.getShapeStyle(data, dsData, this.polygonStyleInfo?.patternId).subscribe((styleInfo) => {
this.polygonStyleInfo = styleInfo;
if (this.polygon) {
this.polygon.setStyle(this.polygonStyleInfo.style);
}
});
this.polygonContainer = L.featureGroup(); this.polygonContainer = L.featureGroup();
this.polygon.addTo(this.polygonContainer); this.polygon.addTo(this.polygonContainer);
@ -81,13 +94,15 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
} }
protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void { protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData); this.dataLayer.getShapeStyle(data, dsData, this.polygonStyleInfo?.patternId).subscribe((styleInfo) => {
this.updatePolygonShape(data); this.polygonStyleInfo = styleInfo;
this.updateTooltip(data, dsData); this.updatePolygonShape(data);
this.updateLabel(data, dsData); this.updateTooltip(data, dsData);
if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) { this.updateLabel(data, dsData);
this.polygon.setStyle(this.polygonStyle); if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) {
} this.polygon.setStyle(this.polygonStyleInfo.style);
}
});
} }
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void { protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void {
@ -230,7 +245,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
this.polygonContainer.closePopup(); this.polygonContainer.closePopup();
this.editing = true; this.editing = true;
this.polygon.options.bubblingMouseEvents = true; this.polygon.options.bubblingMouseEvents = true;
this.polygon.setStyle({...this.polygonStyle, dashArray: '5 5', weight: 3, this.polygon.setStyle({...this.polygonStyleInfo.style, dashArray: '5 5', weight: 3,
color: '#3388ff', opacity: 1, fillColor: '#3388ff', fillOpacity: 0.2}); color: '#3388ff', opacity: 1, fillColor: '#3388ff', fillOpacity: 0.2});
this.addItemClass('tb-cut-mode'); this.addItemClass('tb-cut-mode');
this.polygon.once('pm:cut', (e) => { this.polygon.once('pm:cut', (e) => {
@ -238,7 +253,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
if (this.polygon instanceof L.Rectangle) { if (this.polygon instanceof L.Rectangle) {
this.polygonContainer.removeLayer(this.polygon); this.polygonContainer.removeLayer(this.polygon);
this.polygon = L.polygon(e.layer.getLatLngs(), { this.polygon = L.polygon(e.layer.getLatLngs(), {
...this.polygonStyle, ...this.polygonStyleInfo.style,
snapIgnore: !this.dataLayer.isSnappable(), snapIgnore: !this.dataLayer.isSnappable(),
bubblingMouseEvents: !this.dataLayer.isEditMode() bubblingMouseEvents: !this.dataLayer.isEditMode()
}); });
@ -284,7 +299,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
private disablePolygonCutMode(cutButton?: L.TB.ToolbarButton) { private disablePolygonCutMode(cutButton?: L.TB.ToolbarButton) {
this.editing = false; this.editing = false;
this.polygon.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); this.polygon.options.bubblingMouseEvents = !this.dataLayer.isEditMode();
this.polygon.setStyle({...this.polygonStyle, dashArray: null}); this.polygon.setStyle({...this.polygonStyleInfo.style, dashArray: null});
this.removeItemClass('tb-cut-mode'); this.removeItemClass('tb-cut-mode');
this.polygon.off('pm:cut'); this.polygon.off('pm:cut');
const map = this.dataLayer.getMap().getMap(); const map = this.dataLayer.getMap().getMap();
@ -338,9 +353,10 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
if (this.polygon instanceof L.Rectangle) { if (this.polygon instanceof L.Rectangle) {
this.polygonContainer.removeLayer(this.polygon); this.polygonContainer.removeLayer(this.polygon);
this.polygon = L.polygon(polyData, { this.polygon = L.polygon(polyData, {
...this.polygonStyle, ...this.polygonStyleInfo.style,
snapIgnore: !this.dataLayer.isSnappable(), snapIgnore: !this.dataLayer.isSnappable(),
bubblingMouseEvents: !this.dataLayer.isEditMode() bubblingMouseEvents: !this.dataLayer.isEditMode(),
noClip: true
}); });
this.polygon.addTo(this.polygonContainer); this.polygon.addTo(this.polygonContainer);
this.editModeUpdated(); this.editModeUpdated();

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

@ -14,45 +14,326 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { DataLayerColorSettings, ShapeDataLayerSettings, TbMapDatasource } from '@shared/models/widget/maps/map.models'; import {
DataLayerColorSettings,
loadImageWithAspect,
ShapeDataLayerSettings,
ShapeFillImageFunction,
ShapeFillImageInfo,
ShapeFillImageSettings,
ShapeFillImageType,
ShapeFillStripeSettings,
ShapeFillType,
TbMapDatasource
} from '@shared/models/widget/maps/map.models';
import L from 'leaflet'; import L from 'leaflet';
import { TbMap } from '@home/components/widget/lib/maps/map'; import { TbMap } from '@home/components/widget/lib/maps/map';
import { forkJoin, Observable } from 'rxjs'; import { forkJoin, Observable, of } from 'rxjs';
import { FormattedData } from '@shared/models/widget.models'; import { FormattedData } from '@shared/models/widget.models';
import { TbLatestMapDataLayer } from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; import { TbLatestMapDataLayer } from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer';
import { DataLayerColorProcessor } from './map-data-layer'; import { DataLayerColorProcessor, TbMapDataLayer } from './map-data-layer';
import { map } from 'rxjs/operators';
import { isDefinedAndNotNull, objectHashCode, parseTbFunction, safeExecuteTbFunction } from '@core/utils';
import { CompiledTbFunction } from '@shared/models/js-function.models';
import { ImagePipe } from '@shared/pipe/image.pipe';
export type ShapePatternStorage = {[id: string]: {
pattern: L.TB.Pattern;
refCount: number;
}};
interface ShapePatternInfo {
type: ShapeFillType;
fillColor?: string;
fillImage?: {
image: string;
width: number;
height: number;
opacity?: number;
angle?: number;
scale?: number;
};
fillStripe?: {
weight: number;
color: string;
spaceWeight: number;
spaceColor: string;
angle: number;
}
}
interface PatternWithId {
patternId: string;
pattern: L.TB.Pattern;
}
export interface ShapeStyleInfo {
patternId: string;
style: L.PathOptions;
}
abstract class ShapePatternProcessor<S = any> {
static fromSettings(dataLayer: TbMapDataLayer,
settings: ShapeDataLayerSettings): ShapePatternProcessor {
switch (settings.fillType) {
case ShapeFillType.color:
return new ShapeColorPatternProcessor(dataLayer, settings.fillColor);
case ShapeFillType.image:
return new ShapeImagePatternProcessor(dataLayer, settings.fillImage);
case ShapeFillType.stripe:
return new ShapeStripePatternProcessor(dataLayer, settings.fillStripe);
}
}
protected constructor(protected dataLayer: TbMapDataLayer,
protected settings: S) {}
public abstract setup(): Observable<any>;
protected abstract computePattern(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[]): Observable<ShapePatternInfo>;
public processPattern(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[], prevPatternId?: string): Observable<PatternWithId> {
return this.computePattern(data, dsData).pipe(
map((patternInfo) => this.patternFromPatternInfo(patternInfo, prevPatternId))
);
}
private patternFromPatternInfo(patternInfo: ShapePatternInfo, prevPatternId?: string): PatternWithId {
const patternId = objectHashCode(patternInfo) + '';
let pattern = this.dataLayer.getMap().useShapePattern(patternId, prevPatternId);
if (!pattern) {
pattern = this.constructPattern(patternInfo);
this.dataLayer.getMap().storeShapePattern(patternId, pattern);
}
return {
pattern,
patternId
};
}
private constructPattern(patternInfo: ShapePatternInfo): L.TB.Pattern {
let pattern: L.TB.Pattern;
if (patternInfo.type === ShapeFillType.color) {
pattern = new L.TB.Pattern({width: 1, height: 1});
const fillRect = new L.TB.PatternRect({x: 0, y: 0, width: 1, height: 1,
fillOpacity: 1, stroke: false, fill: true, fillColor: patternInfo.fillColor});
pattern.addElement(fillRect);
} else if (patternInfo.type === ShapeFillType.image) {
pattern = new L.TB.Pattern({
width: 1,
height: 1,
patternUnits: 'objectBoundingBox',
patternContentUnits: 'objectBoundingBox',
preserveAspectRatioAlign: 'xMidYMid',
preserveAspectRatioMeetOrSlice: 'slice',
viewBox: [0,0,patternInfo.fillImage.width,patternInfo.fillImage.height]
});
const imagePatternShape = new L.TB.PatternImage({
imageUrl: patternInfo.fillImage.image,
width: patternInfo.fillImage.width,
height: patternInfo.fillImage.height,
opacity: patternInfo.fillImage.opacity,
angle: patternInfo.fillImage.angle,
scale: patternInfo.fillImage.scale
});
pattern.addElement(imagePatternShape);
} else if (patternInfo.type === ShapeFillType.stripe) {
const stripeInfo = patternInfo.fillStripe;
const height = stripeInfo.weight + stripeInfo.spaceWeight;
pattern = new L.TB.Pattern({width: 8, height, angle: stripeInfo.angle});
const stripePattern = new L.TB.PatternPath({
d: 'M0 ' + stripeInfo.weight / 2 + ' H ' + 8,
stroke: true,
weight: stripeInfo.weight,
color: stripeInfo.color,
opacity: 1
});
pattern.addElement(stripePattern);
const spacePattern = new L.TB.PatternPath({
d: 'M0 ' + (stripeInfo.weight + stripeInfo.spaceWeight / 2) + ' H ' + 8,
stroke: true,
weight: stripeInfo.spaceWeight,
color: stripeInfo.spaceColor,
opacity: 1
});
pattern.addElement(spacePattern);
}
return pattern;
}
}
class ShapeColorPatternProcessor extends ShapePatternProcessor<DataLayerColorSettings> {
private fillColorProcessor: DataLayerColorProcessor;
constructor(protected dataLayer: TbMapDataLayer,
protected settings: DataLayerColorSettings) {
super(dataLayer, settings);
}
public setup(): Observable<any> {
this.fillColorProcessor = new DataLayerColorProcessor(this.dataLayer, this.settings);
return this.fillColorProcessor.setup();
}
protected computePattern(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<ShapePatternInfo> {
const fillColor = this.fillColorProcessor.processColor(data, dsData);
const shapePatternInfo: ShapePatternInfo = {
type: ShapeFillType.color,
fillColor
};
return of(shapePatternInfo);
}
}
class ShapeImagePatternProcessor extends ShapePatternProcessor<ShapeFillImageSettings> {
private shapeFillImageFunction: CompiledTbFunction<ShapeFillImageFunction>;
constructor(protected dataLayer: TbMapDataLayer,
protected settings: ShapeFillImageSettings) {
super(dataLayer, settings);
}
public setup(): Observable<any> {
if (this.settings.type === ShapeFillImageType.function) {
return parseTbFunction<ShapeFillImageFunction>(this.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe(
map((parsed) => {
this.shapeFillImageFunction = parsed;
return null;
})
);
} else {
return of(null);
}
}
protected computePattern(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<ShapePatternInfo> {
let currentImage: ShapeFillImageInfo;
if (this.settings.type === ShapeFillImageType.function) {
currentImage = safeExecuteTbFunction(this.shapeFillImageFunction, [data, this.settings.images, dsData]);
}
if (!currentImage?.url) {
currentImage = {
url: this.settings.image,
opacity: this.settings.opacity,
angle: this.settings.angle,
scale: this.settings.scale
};
}
return this.loadPatternInfoFromImage(currentImage);
}
private loadPatternInfoFromImage(image: ShapeFillImageInfo): Observable<ShapePatternInfo> {
const imageUrl = image?.url || '/assets/widget-preview-empty.svg';
const opacity = isDefinedAndNotNull(image?.opacity) ? image.opacity : 1;
const imagePipe = this.dataLayer.getCtx().$injector.get(ImagePipe);
return loadImageWithAspect(imagePipe, imageUrl).pipe(
map((res) => {
const shapePatternInfo: ShapePatternInfo = {
type: ShapeFillType.image,
fillImage: {
image: res.url,
width: res.width,
height: res.height,
opacity,
angle: image?.angle,
scale: image?.scale
}
};
return shapePatternInfo;
})
);
}
}
class ShapeStripePatternProcessor extends ShapePatternProcessor<ShapeFillStripeSettings> {
private colorProcessor: DataLayerColorProcessor;
private spaceColorProcessor: DataLayerColorProcessor;
constructor(protected dataLayer: TbMapDataLayer,
protected settings: ShapeFillStripeSettings) {
super(dataLayer, settings);
}
public setup(): Observable<any> {
this.colorProcessor = new DataLayerColorProcessor(this.dataLayer, this.settings.color);
this.spaceColorProcessor = new DataLayerColorProcessor(this.dataLayer, this.settings.spaceColor);
return forkJoin([this.colorProcessor.setup(), this.spaceColorProcessor.setup()]);
}
protected computePattern(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<ShapePatternInfo> {
const color = this.colorProcessor.processColor(data, dsData);
const spaceColor = this.spaceColorProcessor.processColor(data, dsData);
return of({
type: ShapeFillType.stripe,
fillStripe: {
color,
spaceColor,
angle: this.settings.angle,
weight: this.settings.weight,
spaceWeight: this.settings.spaceWeight
}
});
}
}
export abstract class TbShapesDataLayer<S extends ShapeDataLayerSettings, L extends TbLatestMapDataLayer<S,L>> extends TbLatestMapDataLayer<S, L> { export abstract class TbShapesDataLayer<S extends ShapeDataLayerSettings, L extends TbLatestMapDataLayer<S,L>> extends TbLatestMapDataLayer<S, L> {
public fillColorProcessor: DataLayerColorProcessor; private shapePatternProcessor: ShapePatternProcessor;
public strokeColorProcessor: DataLayerColorProcessor; private strokeColorProcessor: DataLayerColorProcessor;
protected constructor(protected map: TbMap<any>, protected constructor(protected map: TbMap<any>,
inputSettings: S) { inputSettings: S) {
super(map, inputSettings); super(map, inputSettings);
} }
public getShapeStyle(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.PathOptions { public getShapeStyle(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[], fillPatternId: string): Observable<ShapeStyleInfo> {
const fill = this.fillColorProcessor.processColor(data, dsData); return this.shapePatternProcessor.processPattern(data, dsData, fillPatternId).pipe(
const stroke = this.strokeColorProcessor.processColor(data, dsData); map((patternWithId) => {
return { const stroke = this.strokeColorProcessor.processColor(data, dsData);
fill: true, const style: L.PathOptions = {
fillColor: fill, fill: true,
color: stroke, fillPattern: patternWithId.pattern,
weight: this.settings.strokeWeight, color: stroke,
fillOpacity: 1, weight: this.settings.strokeWeight,
opacity: 1 fillOpacity: 1,
}; opacity: 1
};
return {
patternId: patternWithId.patternId,
style
}
})
);
} }
protected allColorSettings(): DataLayerColorSettings[] { protected allColorSettings(): DataLayerColorSettings[] {
return [this.settings.fillColor, this.settings.strokeColor]; const colorSettings: DataLayerColorSettings[] = [this.settings.strokeColor];
if (this.settings.fillType === ShapeFillType.color) {
colorSettings.push(this.settings.fillColor)
} else if (this.settings.fillType === ShapeFillType.stripe) {
if (this.settings.fillStripe?.color) {
colorSettings.push(this.settings.fillStripe.color);
}
if (this.settings.fillStripe?.spaceColor) {
colorSettings.push(this.settings.fillStripe.spaceColor);
}
}
return colorSettings;
} }
protected doSetup(): Observable<any> { protected doSetup(): Observable<any> {
this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor); this.shapePatternProcessor = ShapePatternProcessor.fromSettings(this, this.settings);
this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor); this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor);
return forkJoin([this.fillColorProcessor.setup(), this.strokeColorProcessor.setup()]); return forkJoin([this.shapePatternProcessor.setup(), this.strokeColorProcessor.setup()]);
} }
} }

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

@ -15,7 +15,7 @@
/// ///
import L, { TB } from 'leaflet'; import L, { TB } from 'leaflet';
import { guid, isNotEmptyStr } from '@core/utils'; import { guid, isDefinedAndNotNull, isNotEmptyStr } from '@core/utils';
import 'leaflet-providers'; import 'leaflet-providers';
import { Map as MapLibreGLMap, LngLat as MapLibreGLLngLat } from 'maplibre-gl'; import { Map as MapLibreGLMap, LngLat as MapLibreGLLngLat } from 'maplibre-gl';
import '@geoman-io/leaflet-geoman-free'; import '@geoman-io/leaflet-geoman-free';
@ -27,6 +27,64 @@ import { of } from 'rxjs';
L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true }); L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true });
L.Map.addInitHook(function () {
this._patterns = {};
});
L.Map.include({
addPattern: function (pattern: Pattern) {
const id = L.stamp(pattern);
if (this._patterns[id]) {
return pattern;
}
this._patterns[id] = pattern;
this.whenReady(() => {
pattern.onAdd(this);
});
return this;
},
removePattern: function (pattern: Pattern) {
const id = L.stamp(pattern);
if (!this._patterns[id]) {
return this;
}
if (this._loaded) {
pattern.onRemove(this);
}
delete this._patterns[id];
if (this._loaded) {
this.fire('patternremove', {pattern: pattern});
pattern.fire('remove');
}
pattern._map = null;
return this;
},
_initDefRoot: function () {
if (!this._defRoot) {
const renderer: L.Renderer = this.getRenderer(this);
this._defRoot = Pattern.prototype._createElement('defs');
((renderer as any)._container).appendChild(this._defRoot);
}
}
});
L.SVG.include({
_superUpdateStyle: (L.SVG.prototype as any)._updateStyle,
_updateStyle: function (layer: L.Layer){
this._superUpdateStyle(layer);
const options: L.PathOptions = layer.options;
if (options.fill && options.fillPattern) {
((layer as any)._path as SVGElement).setAttribute('fill', 'url(#' + L.stamp(options.fillPattern) + ")");
}
}
})
class SidebarControl extends L.Control<TB.SidebarControlOptions> implements L.TB.SidebarControl { class SidebarControl extends L.Control<TB.SidebarControlOptions> implements L.TB.SidebarControl {
private readonly sidebar: JQuery<HTMLElement>; private readonly sidebar: JQuery<HTMLElement>;
@ -572,6 +630,373 @@ class BottomToolbarControl implements L.TB.BottomToolbarControl {
} }
class Pattern extends L.Evented implements L.TB.Pattern {
_map: L.Map;
_dom: SVGPatternElement & HTMLElement;
private options: L.TB.PatternOptions = {
x: 0,
y: 0,
width: 8,
height: 8,
patternUnits: 'userSpaceOnUse',
patternContentUnits: 'userSpaceOnUse'
};
private _elements: {[id: string]: PatternElement} = {};
constructor(options: L.TB.PatternOptions) {
super();
this.options = {...this.options, ...options};
}
onAdd(map: L.Map): void {
this._map = map;
this._map._initDefRoot();
this._initDom();
for (const i in this._elements) {
this._elements[i].onAdd(this);
}
this._addElements();
this._addDom();
this.redraw();
this.fire('add');
this._map.fire('patternadd', {pattern: this});
}
onRemove(_map: L.Map): void {
this._removeDom();
}
redraw(): this {
if (this._map) {
this._update();
for (const i in this._elements) {
this._elements[i].redraw();
}
}
return this;
}
setStyle(style: L.TB.PatternOptions): this {
L.setOptions(this, style);
if (this._map) {
this._updateStyle();
this.redraw();
}
return this;
}
addTo(map: L.Map): this {
map.addPattern(this);
return this;
}
remove(): this {
return this.removeFrom(this._map);
}
removeFrom(map: L.Map): this {
if (map) {
map.removePattern(this);
}
return this;
}
addElement(element: PatternElement): PatternElement | undefined {
const id = L.stamp(element);
if (this._elements[id]) {
return element;
}
this._elements[id] = element;
element.onAdd(this);
}
_createElement<E extends SVGElement> (name: string): E {
return document.createElementNS("http://www.w3.org/2000/svg", name) as E;
}
_initDom(): void {
this._dom = this._createElement('pattern');
if (this.options.className) {
L.DomUtil.addClass(this._dom, this.options.className);
}
this._updateStyle();
}
_addDom(): void {
this._map._defRoot.appendChild(this._dom);
}
_removeDom(): void {
L.DomUtil.remove(this._dom);
}
_updateStyle(): void {
const dom = this._dom;
const options = this.options;
if (!dom) { return; }
dom.setAttribute('id', `${L.stamp(this)}`);
dom.setAttribute('x', `${options.x}`);
dom.setAttribute('y', `${options.y}`);
dom.setAttribute('width', `${options.width}`);
dom.setAttribute('height', `${options.height}`);
dom.setAttribute('patternUnits', options.patternUnits);
dom.setAttribute('patternContentUnits', options.patternContentUnits);
if (options.patternTransform || options.angle) {
let transform = options.patternTransform ? options.patternTransform + " " : "";
transform += options.angle ? "rotate(" + options.angle + ") " : "";
dom.setAttribute('patternTransform', transform);
} else {
dom.removeAttribute('patternTransform');
}
if (options.viewBox) {
dom.setAttribute('viewBox', options.viewBox.join(' '));
} else {
dom.removeAttribute('viewBox');
}
if (options.preserveAspectRatioAlign) {
let preserveAspectRatioValue = options.preserveAspectRatioAlign;
if (preserveAspectRatioValue !== 'none' && options.preserveAspectRatioMeetOrSlice) {
preserveAspectRatioValue += (' ' + options.preserveAspectRatioMeetOrSlice);
}
dom.setAttribute('preserveAspectRatio', preserveAspectRatioValue);
} else {
dom.removeAttribute('preserveAspectRatio');
}
for (const i in this._elements) {
this._elements[i]._updateStyle();
}
}
protected _addElements() {};
protected _update() {};
}
abstract class PatternElement<O extends L.TB.PatternElementOptions = L.TB.PatternElementOptions> extends L.Class implements L.TB.PatternElement {
protected options: O;
protected _pattern: Pattern;
protected _dom: SVGElement & HTMLElement;
protected constructor(options: L.TB.PatternElementOptions) {
super();
this.options = {...this._defaultOptions(), ...options};
}
onAdd(pattern: Pattern): void {
this._pattern = pattern;
if (this._pattern._dom) {
this._initDom();
this._addDom();
}
}
addTo(pattern: Pattern): this {
pattern.addElement(this);
return this;
}
redraw(): this {
if (this._pattern) {
this._updateElement();
}
return this;
}
setStyle(style: L.TB.PatternElementOptions): this {
L.setOptions(this, style);
if (this._pattern) {
this._updateStyle();
}
return this;
}
_createElement<E extends SVGElement> (name: string): E {
return document.createElementNS("http://www.w3.org/2000/svg", name) as E;
}
_initDomElement(type: string): void {
this._dom = this._createElement(type);
if (this.options.className) {
L.DomUtil.addClass(this._dom, this.options.className);
}
this._updateStyle();
}
_addDom(): void {
this._pattern._dom.appendChild(this._dom);
}
_updateStyle(): void {}
protected _initDom() {}
protected _updateElement() {}
protected abstract _defaultOptions(): O;
}
const defaultPatternShapeOptions: L.TB.PatternShapeOptions = {
stroke: true,
color: '#3388ff',
weight: 3,
opacity: 1,
lineCap: 'round',
lineJoin: 'round',
fillOpacity: 0.2,
fillRule: 'evenodd'
};
abstract class PatternShape<O extends L.TB.PatternShapeOptions> extends PatternElement<O> implements L.TB.PatternShape {
protected constructor(options: O) {
super(options);
}
_updateStyle(): void {
const dom = this._dom;
const options = this.options;
if (!dom) { return; }
if (options.stroke) {
dom.setAttribute('stroke', options.color);
dom.setAttribute('stroke-opacity', `${options.opacity}`);
dom.setAttribute('stroke-width', `${options.weight}`);
dom.setAttribute('stroke-linecap', options.lineCap);
dom.setAttribute('stroke-linejoin', options.lineJoin);
if (options.dashArray) {
dom.setAttribute('stroke-dasharray', options.dashArray.join(' '));
} else {
dom.removeAttribute('stroke-dasharray');
}
if (options.dashOffset) {
dom.setAttribute('stroke-dashoffset', `${options.dashOffset}`);
} else {
dom.removeAttribute('stroke-dashoffset');
}
} else {
dom.setAttribute('stroke', 'none');
}
if (options.fill) {
if (options.fillPattern) {
dom.setAttribute('fill', 'url(#' + L.stamp(options.fillPattern) + ")");
}
else {
dom.setAttribute('fill', options.fillColor || options.color);
}
dom.setAttribute('fill-opacity', `${options.fillOpacity}`);
dom.setAttribute('fill-rule', options.fillRule || 'evenodd');
} else {
dom.setAttribute('fill', 'none');
}
dom.setAttribute('pointer-events', options.pointerEvents || (options.interactive ? 'visiblePainted' : 'none'));
}
}
class PatternRect extends PatternShape<L.TB.PatternRectOptions> implements L.TB.PatternRect {
constructor(options: L.TB.PatternRectOptions) {
super(options);
}
protected _initDom() {
this._initDomElement('rect');
}
protected _updateElement() {
if (!this._dom) { return; }
this._dom.setAttribute('x', `${this.options.x}`);
this._dom.setAttribute('y', `${this.options.y}`);
this._dom.setAttribute('width', `${this.options.width}`);
this._dom.setAttribute('height', `${this.options.height}`);
if (this.options.rx) { this._dom.setAttribute('rx', `${this.options.rx}`); }
if (this.options.ry) { this._dom.setAttribute('ry', `${this.options.ry}`); }
}
protected _defaultOptions(): L.TB.PatternRectOptions {
return {
x: 0,
y: 0,
width: 10,
height: 10,
...defaultPatternShapeOptions
};
}
}
class PatternPath extends PatternShape<L.TB.PatternPathOptions> implements L.TB.PatternPath {
constructor(options: L.TB.PatternPathOptions) {
super(options);
}
protected _initDom() {
this._initDomElement('path');
}
protected _updateElement() {
if (!this._dom) { return; }
this._dom.setAttribute('d', this.options.d);
}
protected _defaultOptions(): L.TB.PatternPathOptions {
return {...defaultPatternShapeOptions};
}
}
class PatternImage extends PatternElement<L.TB.PatternImageOptions> implements L.TB.PatternImage {
constructor(options: TB.PatternImageOptions) {
super(options);
}
protected _initDom() {
this._initDomElement('image');
}
_updateStyle(): void {
const dom = this._dom;
const options = this.options;
if (!dom) { return; }
this._dom.setAttribute('href', options.imageUrl);
this._dom.setAttribute('opacity', isDefinedAndNotNull(options.opacity) ? `${options.opacity}` : '1');
this._dom.setAttribute('x', '0');
this._dom.setAttribute('y', '0');
this._dom.setAttribute('width', `${options.width}`);
this._dom.setAttribute('height', `${options.height}`);
this._dom.setAttribute('preserveAspectRatio', 'xMidYMid slice');
const transforms: string[] = [];
if (options.angle) {
transforms.push(`rotate(${options.angle})`);
}
if (options.scale && options.scale !== 1) {
transforms.push(`scale(${options.scale})`);
}
if (transforms.length) {
this._dom.setAttribute('transform', transforms.join(' '));
this._dom.setAttribute('transform-origin', `${options.width/2} ${options.height/2}`);
}
}
protected _defaultOptions(): L.TB.PatternImageOptions {
return {
imageUrl: '',
width: 0,
height: 0
};
}
}
const sidebar = (options: TB.SidebarControlOptions): L.TB.SidebarControl => { const sidebar = (options: TB.SidebarControlOptions): L.TB.SidebarControl => {
return new SidebarControl(options); return new SidebarControl(options);
} }
@ -952,6 +1377,12 @@ L.TB = L.TB || {
ToolbarButton, ToolbarButton,
ToolbarControl, ToolbarControl,
BottomToolbarControl, BottomToolbarControl,
Pattern,
PatternElement,
PatternShape,
PatternRect,
PatternPath,
PatternImage,
sidebar, sidebar,
sidebarPane, sidebarPane,
layers, layers,

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

@ -82,6 +82,7 @@ import { TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide;
import { ShapePatternStorage } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer';
type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]};
@ -132,6 +133,8 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected addPolygonDataLayers: TbLatestMapDataLayer<any>[]; protected addPolygonDataLayers: TbLatestMapDataLayer<any>[];
protected addCircleDataLayers: TbLatestMapDataLayer<any>[]; protected addCircleDataLayers: TbLatestMapDataLayer<any>[];
protected shapePatternStorage: ShapePatternStorage = {};
private readonly mapResize$: ResizeObserver; private readonly mapResize$: ResizeObserver;
private tooltipInstances: TooltipInstancesData[] = []; private tooltipInstances: TooltipInstancesData[] = [];
@ -333,7 +336,11 @@ export abstract class TbMap<S extends BaseMapSettings> {
type: widgetType.latest, type: widgetType.latest,
callbacks: { callbacks: {
onDataUpdated: (subscription) => { onDataUpdated: (subscription) => {
this.update(subscription); try {
this.update(subscription);
} catch (e) {
console.error(e);
}
} }
} }
}; };
@ -367,10 +374,18 @@ export abstract class TbMap<S extends BaseMapSettings> {
type: widgetType.timeseries, type: widgetType.timeseries,
callbacks: { callbacks: {
onDataUpdated: (subscription) => { onDataUpdated: (subscription) => {
this.updateTrips(subscription); try {
this.updateTrips(subscription);
} catch (e) {
console.error(e);
}
}, },
onLatestDataUpdated: (subscription) => { onLatestDataUpdated: (subscription) => {
this.updateTripsWithLatestData(subscription); try {
this.updateTripsWithLatestData(subscription);
} catch (e) {
console.error(e);
}
} }
} }
}; };
@ -1089,6 +1104,42 @@ export abstract class TbMap<S extends BaseMapSettings> {
return this.settings.mapType; return this.settings.mapType;
} }
public useShapePattern(patternId: string, prevPatternId?: string): L.TB.Pattern {
if (prevPatternId && patternId !== prevPatternId) {
this.unUseShapePattern(prevPatternId);
}
if (this.shapePatternStorage[patternId]) {
const patternItem = this.shapePatternStorage[patternId];
if (patternId !== prevPatternId) {
patternItem.refCount++;
return patternItem.pattern;
} else {
return patternItem.pattern;
}
}
}
public unUseShapePattern(patternId: string): void {
if (patternId) {
const patternItem = this.shapePatternStorage[patternId];
if (patternItem) {
patternItem.refCount--;
if (patternItem.refCount === 0) {
patternItem.pattern.remove();
delete this.shapePatternStorage[patternId];
}
}
}
}
public storeShapePattern(patternId: string, pattern: L.TB.Pattern): void {
pattern.addTo(this.map);
this.shapePatternStorage[patternId] = {
pattern,
refCount: 1
};
}
public enabledDataLayersUpdated() { public enabledDataLayersUpdated() {
this.updateEditButtonsStates(); this.updateEditButtonsStates();
this.updateTripsAnchors(); this.updateTripsAnchors();

7
ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts

@ -15,10 +15,7 @@
/// ///
import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { TbPopoverComponent } from '@shared/components/popover.component'; import { TbPopoverComponent } from '@shared/components/popover.component';
import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer';
@ -29,7 +26,7 @@ import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer
styleUrls: ['./select-map-entity-panel.component.scss'], styleUrls: ['./select-map-entity-panel.component.scss'],
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class SelectMapEntityPanelComponent extends PageComponent implements OnInit { export class SelectMapEntityPanelComponent implements OnInit {
@Input() @Input()
entities: UnplacedMapDataItem[]; entities: UnplacedMapDataItem[];
@ -42,9 +39,7 @@ export class SelectMapEntityPanelComponent extends PageComponent implements OnIn
selectedEntity: UnplacedMapDataItem = null; selectedEntity: UnplacedMapDataItem = null;
constructor(private fb: UntypedFormBuilder, constructor(private fb: UntypedFormBuilder,
protected store: Store<AppState>,
private popover: TbPopoverComponent) { private popover: TbPopoverComponent) {
super(store);
} }
ngOnInit(): void { ngOnInit(): void {

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

@ -409,17 +409,42 @@
</div> </div>
</ng-container> </ng-container>
<ng-container *ngIf="['polygons', 'circles'].includes(dataLayerType)"> <ng-container *ngIf="['polygons', 'circles'].includes(dataLayerType)">
<div class="tb-form-row space-between"> <div class="tb-form-panel stroked">
<div translate>widgets.maps.data-layer.fill-color</div> <div class="flex flex-1 flex-row items-center justify-between xs:flex-col xs:items-start xs:gap-3">
<tb-data-layer-color-settings <div class="tb-form-panel-title">{{ 'widgets.maps.data-layer.shape.fill' | translate }}</div>
[context]="context" <tb-toggle-select formControlName="fillType" (click)="$event.stopPropagation()">
[dsType]="dataLayerFormGroup.get('dsType').value" <tb-toggle-option [value]="ShapeFillType.color">{{ 'widgets.maps.data-layer.shape.fill-type-color' | translate }}</tb-toggle-option>
[dsEntityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value" <tb-toggle-option [value]="ShapeFillType.stripe">{{ 'widgets.maps.data-layer.shape.fill-type-stripe' | translate }}</tb-toggle-option>
[dsDeviceId]="dataLayerFormGroup.get('dsDeviceId').value" <tb-toggle-option [value]="ShapeFillType.image">{{ 'widgets.maps.data-layer.shape.fill-type-image' | translate }}</tb-toggle-option>
helpId="{{ dataLayerType === 'polygons' ? 'widget/lib/map/polygon_fill_color_fn' : 'widget/lib/map/circle_fill_color_fn' }}" formControlName="fillColor"></tb-data-layer-color-settings> </tb-toggle-select>
</div>
<div *ngIf="dataLayerFormGroup.get('fillType').value === ShapeFillType.color" class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.color</div>
<tb-data-layer-color-settings
[context]="context"
[dsType]="dataLayerFormGroup.get('dsType').value"
[dsEntityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value"
[dsDeviceId]="dataLayerFormGroup.get('dsDeviceId').value"
helpId="{{ dataLayerType === 'polygons' ? 'widget/lib/map/polygon_fill_color_fn' : 'widget/lib/map/circle_fill_color_fn' }}" formControlName="fillColor"></tb-data-layer-color-settings>
</div>
<div *ngIf="dataLayerFormGroup.get('fillType').value === ShapeFillType.stripe" class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.stripe-pattern</div>
<tb-shape-fill-stripe-settings
[context]="context"
[dsType]="dataLayerFormGroup.get('dsType').value"
[dsEntityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value"
[dsDeviceId]="dataLayerFormGroup.get('dsDeviceId').value"
[dataLayerType]="dataLayerType"
formControlName="fillStripe">
</tb-shape-fill-stripe-settings>
</div>
<div *ngIf="dataLayerFormGroup.get('fillType').value === ShapeFillType.image" class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.image</div>
<tb-shape-fill-image-settings formControlName="fillImage"></tb-shape-fill-image-settings>
</div>
</div> </div>
<div class="tb-form-row space-between"> <div class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.stroke</div> <div translate>widgets.maps.data-layer.shape.stroke</div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="strokeWeight" placeholder="{{ 'widget-config.set' | translate }}"> <input matInput type="number" min="0" formControlName="strokeWeight" placeholder="{{ 'widget-config.set' | translate }}">

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

@ -30,7 +30,7 @@ import {
pathDecoratorSymbols, pathDecoratorSymbols,
pathDecoratorSymbolTranslationMap, pathDecoratorSymbolTranslationMap,
PolygonsDataLayerSettings, PolygonsDataLayerSettings,
ShapeDataLayerSettings, ShapeDataLayerSettings, ShapeFillType,
TripsDataLayerSettings, TripsDataLayerSettings,
updateDataKeyToNewDsType updateDataKeyToNewDsType
} from '@shared/models/widget/maps/map.models'; } from '@shared/models/widget/maps/map.models';
@ -78,6 +78,8 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
MarkerType = MarkerType; MarkerType = MarkerType;
ShapeFillType = ShapeFillType;
datasourceTypes: Array<DatasourceType> = []; datasourceTypes: Array<DatasourceType> = [];
datasourceTypesTranslations = datasourceTypeTranslationMap; datasourceTypesTranslations = datasourceTypeTranslationMap;
@ -266,7 +268,10 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
case 'circles': case 'circles':
this.dataLayerEditActions = dataLayerEditActions; this.dataLayerEditActions = dataLayerEditActions;
const shapeDataLayer = this.settings as ShapeDataLayerSettings; const shapeDataLayer = this.settings as ShapeDataLayerSettings;
this.dataLayerFormGroup.addControl('fillType', this.fb.control(shapeDataLayer.fillType, Validators.required));
this.dataLayerFormGroup.addControl('fillColor', this.fb.control(shapeDataLayer.fillColor, Validators.required)); this.dataLayerFormGroup.addControl('fillColor', this.fb.control(shapeDataLayer.fillColor, Validators.required));
this.dataLayerFormGroup.addControl('fillStripe', this.fb.control(shapeDataLayer.fillStripe, Validators.required));
this.dataLayerFormGroup.addControl('fillImage', this.fb.control(shapeDataLayer.fillImage, Validators.required));
this.dataLayerFormGroup.addControl('strokeColor', this.fb.control(shapeDataLayer.strokeColor, Validators.required)); this.dataLayerFormGroup.addControl('strokeColor', this.fb.control(shapeDataLayer.strokeColor, Validators.required));
this.dataLayerFormGroup.addControl('strokeWeight', this.fb.control(shapeDataLayer.strokeWeight, [Validators.required, Validators.min(0)])); this.dataLayerFormGroup.addControl('strokeWeight', this.fb.control(shapeDataLayer.strokeWeight, [Validators.required, Validators.min(0)]));
if (this.dataLayerType === 'polygons') { if (this.dataLayerType === 'polygons') {
@ -280,6 +285,11 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
const circlesDataLayer = this.settings as CirclesDataLayerSettings; const circlesDataLayer = this.settings as CirclesDataLayerSettings;
this.dataLayerFormGroup.addControl('circleKey', this.fb.control(circlesDataLayer.circleKey, Validators.required)); this.dataLayerFormGroup.addControl('circleKey', this.fb.control(circlesDataLayer.circleKey, Validators.required));
} }
this.dataLayerFormGroup.get('fillType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() =>
this.updateValidators()
);
break; break;
} }
this.dataLayerFormGroup.get('dsType').valueChanges.pipe( this.dataLayerFormGroup.get('dsType').valueChanges.pipe(
@ -349,8 +359,9 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
} }
if (this.dataLayerType === 'markers') { if (this.dataLayerType === 'markers') {
this.updateMarkerTypeValidators(); this.updateMarkerTypeValidators();
} } else if (['polygons', 'circles'].includes(this.dataLayerType)) {
if (this.dataLayerType === 'trips') { this.updateFillTypeValidators();
} else if (this.dataLayerType === 'trips') {
const showMarker: boolean = this.dataLayerFormGroup.get('showMarker').value; const showMarker: boolean = this.dataLayerFormGroup.get('showMarker').value;
if (showMarker) { if (showMarker) {
this.dataLayerFormGroup.get('markerType').enable({emitEvent: false}); this.dataLayerFormGroup.get('markerType').enable({emitEvent: false});
@ -442,6 +453,23 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
} }
} }
private updateFillTypeValidators(): void {
const fillType: ShapeFillType = this.dataLayerFormGroup.get('fillType').value;
if (fillType === ShapeFillType.color) {
this.dataLayerFormGroup.get('fillColor').enable({emitEvent: false});
this.dataLayerFormGroup.get('fillStripe').disable({emitEvent: false});
this.dataLayerFormGroup.get('fillImage').disable({emitEvent: false});
} else if (fillType === ShapeFillType.stripe) {
this.dataLayerFormGroup.get('fillColor').disable({emitEvent: false});
this.dataLayerFormGroup.get('fillStripe').enable({emitEvent: false});
this.dataLayerFormGroup.get('fillImage').disable({emitEvent: false});
} else {
this.dataLayerFormGroup.get('fillColor').disable({emitEvent: false});
this.dataLayerFormGroup.get('fillStripe').disable({emitEvent: false});
this.dataLayerFormGroup.get('fillImage').enable({emitEvent: false});
}
}
editKey(keyType: 'xKey' | 'yKey' | 'polygonKey' | 'circleKey') { editKey(keyType: 'xKey' | 'yKey' | 'polygonKey' | 'circleKey') {
const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value; const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value;
this.context.editKey(targetDataKey, this.context.editKey(targetDataKey,

84
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.html

@ -0,0 +1,84 @@
<!--
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.
-->
<div class="tb-shape-fill-image-settings-panel" [formGroup]="shapeFillImageSettingsFormGroup">
<div class="tb-shape-fill-image-settings-title" translate>widgets.maps.data-layer.shape.fill-image</div>
<div class="flex flex-row">
<tb-toggle-select formControlName="type" class="flex-1">
<tb-toggle-option [value]="ShapeFillImageType.image">
{{ 'widgets.maps.data-layer.shape.fill-image-type-image' | translate }}
</tb-toggle-option>
<tb-toggle-option [value]="ShapeFillImageType.function">
{{ 'widgets.maps.data-layer.shape.fill-image-type-function' | translate }}
</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="tb-shape-fill-image-settings-panel-body" [class.!hidden]="shapeFillImageSettingsFormGroup.get('type').value !== ShapeFillImageType.image">
<div class="tb-form-panel no-padding no-border">
<tb-gallery-image-input required formControlName="image"></tb-gallery-image-input>
<div class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.opacity</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="opacity" type="number" min="0" max="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.angle</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="angle" type="number" min="0" max="360" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix>deg</div>
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.scale</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="scale" type="number" min="0" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
</div>
<div class="tb-shape-fill-image-settings-panel-body" [class.!hidden]="shapeFillImageSettingsFormGroup.get('type').value !== ShapeFillImageType.function">
<div class="tb-form-panel no-padding no-border">
<tb-js-func formControlName="imageFunction"
withModules
[functionArgs]="['data', 'images', 'dsData']"
[globalVariables]="functionScopeVariables"
functionTitle="{{ 'widgets.maps.data-layer.shape.fill-image-function' | translate }}"
helpId="widget/lib/map/shape_fill_image_fn">
</tb-js-func>
<tb-multiple-gallery-image-input label="{{ 'widgets.maps.data-layer.shape.fill-images' | translate }}"
formControlName="images">
</tb-multiple-gallery-image-input>
</div>
</div>
<div class="tb-shape-fill-image-settings-panel-buttons">
<span class="flex-1"></span>
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyShapeFillImageSettings()"
[disabled]="shapeFillImageSettingsFormGroup.invalid || !shapeFillImageSettingsFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

54
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.scss

@ -0,0 +1,54 @@
/**
* 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 '../../../../../../../../../scss/constants';
.tb-shape-fill-image-settings-panel {
width: 700px;
max-width: 90vw;
min-height: 300px;
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-xs} {
width: 90vw;
}
.tb-shape-fill-image-settings-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-form-row {
height: auto;
}
.tb-shape-fill-image-settings-panel-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
}
.tb-shape-fill-image-settings-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
}

101
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts

@ -0,0 +1,101 @@
///
/// 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 { Component, DestroyRef, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { WidgetService } from '@core/http/widget.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ShapeFillImageSettings, ShapeFillImageType } from '@shared/models/widget/maps/map.models';
@Component({
selector: 'tb-shape-fill-image-settings-panel',
templateUrl: './shape-fill-image-settings-panel.component.html',
providers: [],
styleUrls: ['./shape-fill-image-settings-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class ShapeFillImageSettingsPanelComponent implements OnInit {
@Input()
shapeFillImageSettings: ShapeFillImageSettings;
@Output()
shapeFillImageSettingsApplied = new EventEmitter<ShapeFillImageSettings>();
ShapeFillImageType = ShapeFillImageType;
shapeFillImageSettingsFormGroup: UntypedFormGroup;
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
constructor(private fb: UntypedFormBuilder,
private popover: TbPopoverComponent,
private widgetService: WidgetService,
private destroyRef: DestroyRef) {
}
ngOnInit(): void {
this.shapeFillImageSettingsFormGroup = this.fb.group(
{
type: [this.shapeFillImageSettings?.type || ShapeFillImageType.image, []],
image: [this.shapeFillImageSettings?.image, [Validators.required]],
opacity: [this.shapeFillImageSettings?.opacity, [Validators.min(0), Validators.max(1)]],
angle: [this.shapeFillImageSettings?.angle, [Validators.min(0), Validators.max(360)]],
scale: [this.shapeFillImageSettings?.scale, [Validators.min(0)]],
imageFunction: [this.shapeFillImageSettings?.imageFunction, [Validators.required]],
images: [this.shapeFillImageSettings?.images, []]
}
);
this.shapeFillImageSettingsFormGroup.get('type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateValidators();
setTimeout(() => {this.popover?.updatePosition();}, 0);
});
this.updateValidators();
}
cancel() {
this.popover?.hide();
}
applyShapeFillImageSettings() {
const shapeFillImageSettings: ShapeFillImageSettings = this.shapeFillImageSettingsFormGroup.value;
this.shapeFillImageSettingsApplied.emit(shapeFillImageSettings);
this.popover?.hide();
}
private updateValidators() {
const type: ShapeFillImageType = this.shapeFillImageSettingsFormGroup.get('type').value;
if (type === ShapeFillImageType.image) {
this.shapeFillImageSettingsFormGroup.get('image').enable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('opacity').enable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('angle').enable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('scale').enable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('imageFunction').disable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('images').disable({emitEvent: false});
} else {
this.shapeFillImageSettingsFormGroup.get('image').disable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('opacity').disable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('angle').disable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('scale').disable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('imageFunction').enable({emitEvent: false});
this.shapeFillImageSettingsFormGroup.get('images').enable({emitEvent: false});
}
}
}

29
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html

@ -0,0 +1,29 @@
<!--
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.
-->
<button type="button"
mat-stroked-button
class="tb-box-button"
[disabled]="disabled"
#matButton
(click)="openImageSettingsPopup($event, matButton)">
<tb-icon matButtonIcon *ngIf="modelValue?.type === ShapeFillImageType.function; else imagePreview">mdi:function-variant</tb-icon>
</button>
<ng-template #imagePreview>
<img width="24px" height="24px" [style.transform]="modelValue?.angle ? ('rotate('+modelValue.angle+'deg)') : ''" [style.opacity]="modelValue?.opacity"
style="object-fit: contain;" [src]="modelValue?.image | image : {preview: true} | async" [class.disabled]="disabled"/>
</ng-template>

96
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts

@ -0,0 +1,96 @@
///
/// 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 { ChangeDetectorRef, Component, forwardRef, Input, Renderer2, ViewContainerRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { ShapeFillImageSettings, ShapeFillImageType } from '@shared/models/widget/maps/map.models';
import {
ShapeFillImageSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component';
@Component({
selector: 'tb-shape-fill-image-settings',
templateUrl: './shape-fill-image-settings.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ShapeFillImageSettingsComponent),
multi: true
}
]
})
export class ShapeFillImageSettingsComponent implements ControlValueAccessor {
@Input()
disabled: boolean;
ShapeFillImageType = ShapeFillImageType;
modelValue: ShapeFillImageSettings;
private propagateChange: (v: any) => void = () => { };
constructor(private popoverService: TbPopoverService,
private renderer: Renderer2,
private cd: ChangeDetectorRef,
private viewContainerRef: ViewContainerRef) {}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
writeValue(value: ShapeFillImageSettings): void {
if (value) {
this.modelValue = value;
}
}
openImageSettingsPopup($event: Event, matButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
this.popoverService.displayPopover({
trigger,
renderer: this.renderer,
componentType: ShapeFillImageSettingsPanelComponent,
hostView: this.viewContainerRef,
preferredPlacement: 'left',
context: {
shapeFillImageSettings: this.modelValue,
},
isModal: true
}).tbComponentRef.instance.shapeFillImageSettingsApplied.subscribe((shapeFillImageSettings) => {
this.modelValue = shapeFillImageSettings;
this.propagateChange(this.modelValue);
this.cd.detectChanges();
});
}
}
}

84
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.html

@ -0,0 +1,84 @@
<!--
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.
-->
<div class="tb-shape-fill-stripe-settings-panel" [formGroup]="shapeFillStripeSettingsFormGroup">
<div class="tb-shape-fill-stripe-settings-title" translate>widgets.maps.data-layer.shape.stripe-pattern</div>
<div class="tb-shape-fill-stripe-settings-panel-body">
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.first-stripe</div>
<div class="flex flex-row items-center gap-2">
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="weight" placeholder="{{ 'widget-config.set' | translate }}">
<span matSuffix>px</span>
</mat-form-field>
<tb-data-layer-color-settings
[context]="context"
[dsType]="dsType"
[dsEntityAliasId]="dsEntityAliasId"
[dsDeviceId]="dsDeviceId"
helpId="{{ dataLayerType === 'polygons' ? 'widget/lib/map/polygon_stroke_color_fn' : 'widget/lib/map/circle_stroke_color_fn' }}" formControlName="color"></tb-data-layer-color-settings>
</div>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.second-stripe</div>
<div class="flex flex-row items-center gap-2">
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="spaceWeight" placeholder="{{ 'widget-config.set' | translate }}">
<span matSuffix>px</span>
</mat-form-field>
<tb-data-layer-color-settings
[context]="context"
[dsType]="dsType"
[dsEntityAliasId]="dsEntityAliasId"
[dsDeviceId]="dsDeviceId"
helpId="{{ dataLayerType === 'polygons' ? 'widget/lib/map/polygon_stroke_color_fn' : 'widget/lib/map/circle_stroke_color_fn' }}" formControlName="spaceColor"></tb-data-layer-color-settings>
</div>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.shape.angle</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="angle" type="number" min="0" max="180" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix>deg</div>
</mat-form-field>
</div>
<div class="tb-shape-fill-stripe-settings-preview">
<div class="tb-shape-fill-stripe-settings-preview-title" translate>
widgets.background.preview
</div>
<div class="tb-shape-fill-stripe-settings-preview-box mat-elevation-z4" [style]="stripePreviewStyle">
</div>
</div>
</div>
</div>
<div class="tb-shape-fill-stripe-settings-panel-buttons">
<span class="flex-1"></span>
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyShapeFillStripeSettings()"
[disabled]="shapeFillStripeSettingsFormGroup.invalid || !shapeFillStripeSettingsFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

80
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.scss

@ -0,0 +1,80 @@
/**
* 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 '../../../../../../../../../scss/constants';
.tb-shape-fill-stripe-settings-panel {
width: 700px;
max-width: 90vw;
min-height: 300px;
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-xs} {
width: 90vw;
}
.tb-shape-fill-stripe-settings-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-form-row {
height: auto;
}
.tb-shape-fill-stripe-settings-panel-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
}
.tb-shape-fill-stripe-settings-preview {
flex: 1;
background: #fff;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
padding: 12px 16px 24px 16px;
align-items: center;
gap: 12px;
}
.tb-shape-fill-stripe-settings-preview-title {
align-self: stretch;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
color: rgba(0, 0, 0, 0.38);
}
.tb-shape-fill-stripe-settings-preview-box {
position: relative;
width: 136px;
height: 118px;
border-radius: 2.666px;
}
.tb-shape-fill-stripe-settings-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
}

106
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts

@ -0,0 +1,106 @@
///
/// 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 { Component, DestroyRef, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MapDataLayerType, ShapeFillStripeSettings } from '@shared/models/widget/maps/map.models';
import { DomSanitizer } from '@angular/platform-browser';
import {
generateStripePreviewUrl
} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component';
import { ComponentStyle } from '@shared/models/widget-settings.models';
import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models';
import { DatasourceType } from '@shared/models/widget.models';
@Component({
selector: 'tb-shape-fill-stripe-settings-panel',
templateUrl: './shape-fill-stripe-settings-panel.component.html',
providers: [],
styleUrls: ['./shape-fill-stripe-settings-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class ShapeFillStripeSettingsPanelComponent implements OnInit {
@Input()
shapeFillStripeSettings: ShapeFillStripeSettings;
@Input()
context: MapSettingsContext;
@Input()
dsType: DatasourceType;
@Input()
dsEntityAliasId: string;
@Input()
dsDeviceId: string;
@Input()
dataLayerType: MapDataLayerType;
@Output()
shapeFillStripeSettingsApplied = new EventEmitter<ShapeFillStripeSettings>();
stripePreviewStyle: ComponentStyle;
shapeFillStripeSettingsFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder,
private sanitizer: DomSanitizer,
private popover: TbPopoverComponent,
private destroyRef: DestroyRef) {
}
ngOnInit(): void {
this.shapeFillStripeSettingsFormGroup = this.fb.group(
{
weight: [this.shapeFillStripeSettings?.weight, [Validators.min(0)]],
color: [this.shapeFillStripeSettings?.color, []],
spaceWeight: [this.shapeFillStripeSettings?.spaceWeight, [Validators.min(0)]],
spaceColor: [this.shapeFillStripeSettings?.spaceColor, []],
angle: [this.shapeFillStripeSettings?.angle, [Validators.min(0), Validators.max(180)]]
}
);
this.shapeFillStripeSettingsFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updatePreview();
});
this.updatePreview();
}
cancel() {
this.popover?.hide();
}
applyShapeFillStripeSettings() {
const shapeFillStripeSettings: ShapeFillStripeSettings = this.shapeFillStripeSettingsFormGroup.value;
this.shapeFillStripeSettingsApplied.emit(shapeFillStripeSettings);
this.popover?.hide();
}
private updatePreview() {
const shapeFillStripeSettings: ShapeFillStripeSettings = this.shapeFillStripeSettingsFormGroup.value;
const previewUrl = generateStripePreviewUrl(shapeFillStripeSettings);
this.stripePreviewStyle = {
background: this.sanitizer.bypassSecurityTrustStyle(`url(${previewUrl}) no-repeat 50% 50% / cover`)
};
}
}

27
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.html

@ -0,0 +1,27 @@
<!--
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.
-->
<button type="button"
mat-stroked-button
class="tb-box-button"
[disabled]="disabled"
#matButton
(click)="openStripeSettingsPopup($event, matButton)">
<img matButtonIcon width="24px" height="24px"
style="object-fit: contain; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.12);"
[src]="stripePreviewUrl" [class.disabled]="disabled"/>
</button>

148
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts

@ -0,0 +1,148 @@
///
/// 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 { ChangeDetectorRef, Component, forwardRef, Input, Renderer2, ViewContainerRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { MapDataLayerType, ShapeFillStripeSettings } from '@shared/models/widget/maps/map.models';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { isDefinedAndNotNull, stringToBase64 } from '@core/utils';
import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models';
import { DatasourceType } from '@shared/models/widget.models';
import {
ShapeFillStripeSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component';
@Component({
selector: 'tb-shape-fill-stripe-settings',
templateUrl: './shape-fill-stripe-settings.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ShapeFillStripeSettingsComponent),
multi: true
}
]
})
export class ShapeFillStripeSettingsComponent implements ControlValueAccessor {
@Input()
disabled: boolean;
@Input()
context: MapSettingsContext;
@Input()
dsType: DatasourceType;
@Input()
dsEntityAliasId: string;
@Input()
dsDeviceId: string;
@Input()
dataLayerType: MapDataLayerType;
modelValue: ShapeFillStripeSettings;
stripePreviewUrl: SafeUrl;
private propagateChange: (v: any) => void = () => { };
constructor(private popoverService: TbPopoverService,
private sanitizer: DomSanitizer,
private renderer: Renderer2,
private cd: ChangeDetectorRef,
private viewContainerRef: ViewContainerRef) {}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
writeValue(value: ShapeFillStripeSettings): void {
if (value) {
this.modelValue = value;
}
this.updatePreview();
}
openStripeSettingsPopup($event: Event, matButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
this.popoverService.displayPopover({
trigger,
renderer: this.renderer,
componentType: ShapeFillStripeSettingsPanelComponent,
hostView: this.viewContainerRef,
preferredPlacement: 'left',
context: {
shapeFillStripeSettings: this.modelValue,
context: this.context,
dsType: this.dsType,
dsEntityAliasId: this.dsEntityAliasId,
dsDeviceId: this.dsDeviceId,
dataLayerType: this.dataLayerType
},
isModal: true
}).tbComponentRef.instance.shapeFillStripeSettingsApplied.subscribe((shapeFillStripeSettings) => {
this.modelValue = shapeFillStripeSettings;
this.updatePreview();
this.propagateChange(this.modelValue);
this.cd.detectChanges();
});
}
}
private updatePreview() {
this.stripePreviewUrl = this.sanitizer.bypassSecurityTrustUrl(generateStripePreviewUrl(this.modelValue));
}
}
export const generateStripePreviewUrl = (settings: ShapeFillStripeSettings): string => {
const weight = isDefinedAndNotNull(settings?.weight) ? settings.weight : 3;
const spaceWeight = isDefinedAndNotNull(settings?.spaceWeight) ? settings.spaceWeight : 9;
const angle = isDefinedAndNotNull(settings?.angle) ? settings.angle : 45;
const height = weight + spaceWeight;
const color = settings?.color?.color || '#8f8f8f';
const spaceColor = settings?.spaceColor?.color || 'rgba(143,143,143,0)';
const svgStr = `<svg x="0" y="0" width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="48" height="48" fill="url(#stripePattern)" fill-opacity="1"></rect>
<defs>
<pattern id="stripePattern" x="0" y="0" width="8" height="${height}" patternUnits="userSpaceOnUse"
patternContentUnits="userSpaceOnUse" patternTransform="rotate(${angle})">
<path d="M0 ${weight/2} H 8" stroke="${color}" stroke-width="${weight}" stroke-opacity="1"></path>
<path d="M0 ${weight + spaceWeight/2} H 8" stroke="${spaceColor}" stroke-width="${spaceWeight}" stroke-opacity="1"></path>
</pattern>
</defs>
</svg>`;
const encodedSvg = stringToBase64(svgStr);
return `data:image/svg+xml;base64,${encodedSvg}`;
}

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

@ -255,6 +255,18 @@ import { MapDataSourcesComponent } from '@home/components/widget/lib/settings/co
import { import {
MapDataSourceRowComponent MapDataSourceRowComponent
} from '@home/components/widget/lib/settings/common/map/map-data-source-row.component'; } from '@home/components/widget/lib/settings/common/map/map-data-source-row.component';
import {
ShapeFillImageSettingsComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-image-settings.component';
import {
ShapeFillImageSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component';
import {
ShapeFillStripeSettingsComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component';
import {
ShapeFillStripeSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -342,6 +354,10 @@ import {
MarkerImageSettingsComponent, MarkerImageSettingsComponent,
MarkerImageSettingsPanelComponent, MarkerImageSettingsPanelComponent,
MarkerClusteringSettingsComponent, MarkerClusteringSettingsComponent,
ShapeFillStripeSettingsComponent,
ShapeFillStripeSettingsPanelComponent,
ShapeFillImageSettingsComponent,
ShapeFillImageSettingsPanelComponent,
MapDataLayerDialogComponent, MapDataLayerDialogComponent,
MapDataLayerRowComponent, MapDataLayerRowComponent,
MapDataLayersComponent, MapDataLayersComponent,

109
ui-ngx/src/app/shared/models/widget/maps/map.models.ts

@ -487,8 +487,40 @@ export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial<Tri
}, },
} as TripsDataLayerSettings); } as TripsDataLayerSettings);
export enum ShapeFillType {
color = 'color',
image = 'image',
stripe = 'stripe'
}
export enum ShapeFillImageType {
image = 'image',
function = 'function'
}
export interface ShapeFillImageSettings {
type: ShapeFillImageType;
image?: string;
opacity?: number; // (0-1)
angle?: number; // (0-360)
scale?: number; // (0-...)
imageFunction?: TbFunction;
images?: string[];
}
export interface ShapeFillStripeSettings {
weight: number;
color: DataLayerColorSettings;
spaceWeight: number;
spaceColor: DataLayerColorSettings;
angle: number; // (0-180)
}
export interface ShapeDataLayerSettings extends MapDataLayerSettings { export interface ShapeDataLayerSettings extends MapDataLayerSettings {
fillColor: DataLayerColorSettings; fillType: ShapeFillType;
fillColor?: DataLayerColorSettings;
fillImage?: ShapeFillImageSettings;
fillStripe?: ShapeFillStripeSettings;
strokeColor: DataLayerColorSettings; strokeColor: DataLayerColorSettings;
strokeWeight: number; strokeWeight: number;
} }
@ -510,10 +542,31 @@ export const defaultPolygonsDataLayerSettings = (mapType: MapType, functionsOnly
} as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings(mapType) as PolygonsDataLayerSettings); } as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings(mapType) as PolygonsDataLayerSettings);
export const defaultBasePolygonsDataLayerSettings = (mapType: MapType): Partial<PolygonsDataLayerSettings> => mergeDeep({ export const defaultBasePolygonsDataLayerSettings = (mapType: MapType): Partial<PolygonsDataLayerSettings> => mergeDeep({
fillType: ShapeFillType.color,
fillColor: { fillColor: {
type: DataLayerColorType.constant, type: DataLayerColorType.constant,
color: 'rgba(51,136,255,0.2)', color: 'rgba(51,136,255,0.2)',
}, },
fillImage: {
type: ShapeFillImageType.image,
image: '/assets/widget-preview-empty.svg',
opacity: 1,
angle: 0,
scale: 1
},
fillStripe: {
weight: 3,
color: {
type: DataLayerColorType.constant,
color: '#8f8f8f'
},
spaceWeight: 9,
spaceColor: {
type: DataLayerColorType.constant,
color: 'rgba(143,143,143,0)',
},
angle: 45
},
strokeColor: { strokeColor: {
type: DataLayerColorType.constant, type: DataLayerColorType.constant,
color: '#3388ff', color: '#3388ff',
@ -539,10 +592,31 @@ export const defaultCirclesDataLayerSettings = (mapType: MapType, functionsOnly
} as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings(mapType) as CirclesDataLayerSettings); } as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings(mapType) as CirclesDataLayerSettings);
export const defaultBaseCirclesDataLayerSettings = (mapType: MapType): Partial<CirclesDataLayerSettings> => mergeDeep({ export const defaultBaseCirclesDataLayerSettings = (mapType: MapType): Partial<CirclesDataLayerSettings> => mergeDeep({
fillType: ShapeFillType.color,
fillColor: { fillColor: {
type: DataLayerColorType.constant, type: DataLayerColorType.constant,
color: 'rgba(51,136,255,0.2)', color: 'rgba(51,136,255,0.2)',
}, },
fillImage: {
type: ShapeFillImageType.image,
image: '/assets/widget-preview-empty.svg',
opacity: 1,
angle: 0,
scale: 1
},
fillStripe: {
weight: 3,
color: {
type: DataLayerColorType.constant,
color: '#8f8f8f'
},
spaceWeight: 9,
spaceColor: {
type: DataLayerColorType.constant,
color: 'rgba(143,143,143,0)',
},
angle: 45
},
strokeColor: { strokeColor: {
type: DataLayerColorType.constant, type: DataLayerColorType.constant,
color: '#3388ff', color: '#3388ff',
@ -596,10 +670,8 @@ export const mapDataSourceValid = (dataSource: MapDataSourceSettings): boolean =
if (dataSource.dsType === DatasourceType.device && !dataSource.dsDeviceId) { if (dataSource.dsType === DatasourceType.device && !dataSource.dsDeviceId) {
return false; return false;
} }
if (dataSource.dsType === DatasourceType.entity && !dataSource.dsEntityAliasId) { return !(dataSource.dsType === DatasourceType.entity && !dataSource.dsEntityAliasId);
return false;
}
return true;
}; };
export const mapDataSourceValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { export const mapDataSourceValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
@ -1112,6 +1184,13 @@ export interface MarkerIconInfo {
size: [number, number]; size: [number, number];
} }
export interface ShapeFillImageInfo {
url: string;
opacity?: number;
angle?: number;
scale?: number;
}
export type MapStringFunction = (data: FormattedData<TbMapDatasource>, export type MapStringFunction = (data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[]) => string; dsData: FormattedData<TbMapDatasource>[]) => string;
@ -1126,6 +1205,9 @@ export type ClusterMarkerColorFunction = (data: FormattedData<TbMapDatasource>[]
export type MarkerPositionFunction = (origXPos: number, origYPos: number, data: FormattedData<TbMapDatasource>, export type MarkerPositionFunction = (origXPos: number, origYPos: number, data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[], aspect: number) => { x: number, y: number }; dsData: FormattedData<TbMapDatasource>[], aspect: number) => { x: number, y: number };
export type ShapeFillImageFunction = (data: FormattedData<TbMapDatasource>, images: string[],
dsData: FormattedData<TbMapDatasource>[]) => ShapeFillImageInfo;
export type TbPolygonRawCoordinate = L.LatLngTuple | L.LatLngTuple[] | L.LatLngTuple[][]; export type TbPolygonRawCoordinate = L.LatLngTuple | L.LatLngTuple[] | L.LatLngTuple[][];
export type TbPolygonRawCoordinates = TbPolygonRawCoordinate[]; export type TbPolygonRawCoordinates = TbPolygonRawCoordinate[];
export type TbPolyData = L.LatLngTuple[] | L.LatLngTuple[][] | L.LatLngTuple[][][]; export type TbPolyData = L.LatLngTuple[] | L.LatLngTuple[][] | L.LatLngTuple[][][];
@ -1271,11 +1353,13 @@ const imageLoader = (imageUrl: string): Observable<HTMLImageElement> => new Obse
image.src = imageUrl; image.src = imageUrl;
}); });
const loadImageAspect = (imageUrl: string): Observable<number> => const loadImageSize = (imageUrl: string): Observable<[number, number]> =>
imageLoader(imageUrl).pipe(map(image => image.width / image.height)); imageLoader(imageUrl).pipe(map(image => [image.width, image.height]));
export interface ImageWithAspect { export interface ImageWithAspect {
url: string; url: string;
width: number;
height: number;
aspect: number; aspect: number;
} }
@ -1289,9 +1373,14 @@ export const loadImageWithAspect = (imagePipe: ImagePipe, imageUrl: string): Obs
return imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).pipe( return imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).pipe(
switchMap((res) => { switchMap((res) => {
const url = res as string; const url = res as string;
return loadImageAspect(url).pipe( return loadImageSize(url).pipe(
map((aspect) => { map((size) => {
imageWithAspect = {url, aspect}; imageWithAspect = {
url,
width: size[0],
height: size[1],
aspect: size[0]/size[1]
};
imageAspectMap[hash] = imageWithAspect; imageAspectMap[hash] = imageWithAspect;
return imageWithAspect; return imageWithAspect;
}) })

52
ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn.md

@ -0,0 +1,52 @@
#### Shape fill image function
<div class="divider"></div>
<br/>
*function (data, images, dsData): {url: string}*
A JavaScript function used to compute shape fill image.
**Parameters:**
<ul>
{% include widget/lib/map/shape_fill_image_fn_args %}
</ul>
**Returns:**
Should return shape fill image data having the following structure:
```typescript
{
url: string;
opacity?: number;
angle?: number;
scale?: number;
}
```
- *url* - fill image url;
- *opacity* - optional image opacity, number value from 0 to 1;
- *angle* - optional image rotation angle, number value from 0 to 360;
- *scale* - optional image scale, number value (1 - original size, smaller value - scale down, bigger value - scale up);
In case no data is returned, default fill image will be used.
<div class="divider"></div>
##### Examples
<ul>
<li>
TODO:
</li>
</ul>
```javascript
TODO:
{:copy-code}
```
<br>
<br>

9
ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn_args.md

@ -0,0 +1,9 @@
<li><b>data:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/b881f1c2985399f9665e033e2479549e97da1f36/ui-ngx/src/app/shared/models/widget.models.ts#L513" target="_blank">FormattedData</a></code> object associated with data layer (markers/polygons/circles) or data point of the route (trips data layer).<br/>
Represents basic entity properties (ex. <code>entityId</code>, <code>entityName</code>)<br/>and provides access to other entity attributes/timeseries declared in datasource of the data layer configuration.
</li>
<li><b>images:</b> <code>string[]</code> - array of image urls configured in the <b>Shape fill images</b> section.
</li>
<li><b>dsData:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/b881f1c2985399f9665e033e2479549e97da1f36/ui-ngx/src/app/shared/models/widget.models.ts#L513" target="_blank">FormattedData[]</a></code> - All available data associated with data layers including additional datasources as array of <a href="https://github.com/thingsboard/thingsboard/blob/b881f1c2985399f9665e033e2479549e97da1f36/ui-ngx/src/app/shared/models/widget.models.ts#L513" target="_blank">FormattedData</a> objects<br/>
resolved from configured datasources. Each object represents basic entity properties (ex. <code>entityId</code>, <code>entityName</code>)<br/>
and provides access to other entity attributes/timeseries declared in datasources of data layers configuration including additional datasources of the map configuration.
</li>

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

@ -8011,8 +8011,6 @@
"groups": "Groups", "groups": "Groups",
"groups-hint": "List of group names assigned to the overlay, used to toggle its visibility on the map.", "groups-hint": "List of group names assigned to the overlay, used to toggle its visibility on the map.",
"color": "Color", "color": "Color",
"fill-color": "Fill color",
"stroke": "Stroke",
"color-settings": "Color settings", "color-settings": "Color settings",
"color-type-constant": "Constant", "color-type-constant": "Constant",
"color-type-range": "Range", "color-type-range": "Range",
@ -8127,6 +8125,27 @@
"points": "Points", "points": "Points",
"point-tooltip": "Point tooltip" "point-tooltip": "Point tooltip"
}, },
"shape": {
"fill": "Fill",
"fill-type-color": "Color",
"fill-type-stripe": "Stripe",
"fill-type-image": "Image",
"color": "Color",
"stripe": "Stripe",
"image": "Image",
"stroke": "Stroke",
"fill-image": "Fill image",
"fill-image-type-image": "Image",
"fill-image-type-function": "Function",
"opacity": "Opacity",
"angle": "Rotation angle",
"scale": "Scale",
"fill-image-function": "Shape fill image function",
"fill-images": "Shape fill images",
"stripe-pattern": "Stripe pattern",
"first-stripe": "First stripe",
"second-stripe": "Second stripe"
},
"polygon": { "polygon": {
"polygon-key": "Polygon key", "polygon-key": "Polygon key",
"polygon-key-required": "Polygon key required", "polygon-key-required": "Polygon key required",

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

@ -25,6 +25,18 @@ declare module 'leaflet' {
interface MarkerOptions { interface MarkerOptions {
tbMarkerData?: FormattedData<TbMapDatasource>; tbMarkerData?: FormattedData<TbMapDatasource>;
} }
interface Map {
_patterns: {[id: number]: L.TB.Pattern};
_defRoot: SVGDefsElement;
addPattern(pattern: L.TB.Pattern): Map;
removePattern(pattern: L.TB.Pattern): Map;
hasPattern(pattern: L.TB.Pattern): boolean;
_initDefRoot(): void;
}
interface PathOptions {
fillPattern?: L.TB.Pattern | undefined;
}
interface TileLayer { interface TileLayer {
_url: string; _url: string;
@ -152,6 +164,109 @@ declare module 'leaflet' {
container: HTMLElement; container: HTMLElement;
} }
interface PathOptions {
fillPattern?: Pattern | undefined;
}
interface PatternOptions {
x?: number | undefined;
y?: number | undefined;
width?: number | undefined;
height?: number | undefined;
patternUnits?: "userSpaceOnUse" | "objectBoundingBox" | undefined;
patternContentUnits?: "userSpaceOnUse" | "objectBoundingBox" | undefined;
patternTransform?: string | null | undefined;
preserveAspectRatioAlign?: "none" | "xMinYMin" | "xMidYMin" | "xMaxYMin" | "xMinYMid" | "xMidYMid" | "xMaxYMid" | "xMinYMax" | "xMidYMax" | "xMaxYMax" | undefined;
preserveAspectRatioMeetOrSlice?: "meet" | "slice" | undefined;
viewBox?: [number, number, number, number] | undefined;
angle?: number | null | undefined;
className?: string | undefined;
}
interface PatternElementOptions {
className?: string | undefined;
}
interface PatternShapeOptions extends PatternElementOptions {
stroke?: boolean | undefined;
color?: string | undefined;
weight?: number | undefined;
opacity?: number | undefined;
lineCap?: "butt" | "round" | "square" | "inherit" | undefined;
lineJoin?: "butt" | "round" | "square" | "inherit" | undefined;
dashArray?: number[] | null | undefined;
dashOffset?: number | null | undefined;
fill?: boolean | undefined;
fillColor?: string | undefined;
fillOpacity?: number | undefined;
fillRule?: "nonzero" | "evenodd" | "inherit" | undefined;
fillPattern?: Pattern | null | undefined;
pointerEvents?: string | undefined;
interactive?: boolean | undefined;
}
interface PatternRectOptions extends PatternShapeOptions {
x?: number | undefined;
y?: number | undefined;
width?: number | undefined;
height?: number | undefined;
rx?: number | null | undefined;
ry?: number | null | undefined;
}
interface PatternPathOptions extends PatternShapeOptions {
d?: string | null | undefined;
}
interface PatternImageOptions extends PatternElementOptions {
imageUrl: string;
width: number;
height: number;
opacity?: number;
angle?: number;
scale?: number;
}
class Pattern extends L.Evented {
constructor(options?: PatternOptions);
onAdd(map: L.Map): void;
onRemove(map: L.Map): void;
redraw(): this;
setStyle(style: PatternOptions): this;
addTo(map: L.Map): this;
remove(): this;
removeFrom(map: L.Map): this;
addElement(element: PatternElement): PatternElement | undefined;
}
abstract class PatternElement extends L.Class {
protected constructor(options?: PatternElementOptions);
onAdd(pattern: Pattern): void;
addTo(pattern: Pattern): this;
redraw(): this;
setStyle(style: PatternElementOptions): this;
}
abstract class PatternShape extends PatternElement {
protected constructor(options?: PatternShapeOptions);
setStyle(style: PatternShapeOptions): this;
}
class PatternRect extends PatternShape {
constructor(options?: PatternRectOptions);
setStyle(style: PatternRectOptions): this;
}
class PatternPath extends PatternShape {
constructor(options?: PatternPathOptions);
setStyle(style: PatternPathOptions): this;
}
class PatternImage extends PatternElement {
constructor(options: PatternImageOptions);
setStyle(style: PatternImageOptions): this;
}
function sidebar(options: SidebarControlOptions): SidebarControl; function sidebar(options: SidebarControlOptions): SidebarControl;
function sidebarPane<O extends SidebarPaneControlOptions>(options: O): SidebarPaneControl<O>; function sidebarPane<O extends SidebarPaneControlOptions>(options: O): SidebarPaneControl<O>;

8
ui-ngx/yarn.lock

@ -1581,10 +1581,10 @@
dependencies: dependencies:
tslib "^2.3.0" tslib "^2.3.0"
"@geoman-io/leaflet-geoman-free@2.17.0": "@geoman-io/leaflet-geoman-free@2.18.3":
version "2.17.0" version "2.18.3"
resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.17.0.tgz#9c8fce5c7a85e5d7ece3a7e5d0b82dbf2329bd81" resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.18.3.tgz#a41489920b175931fba2a1e8e81347f9e3be5481"
integrity sha512-vAY9tKB2I/Ui8d3QUBuebWnunI2sGjsfAUTXMMcf5UpISvPz67io4hpbKXid9GNsW6P4LGv1+ZzrmkpM78GzHA== integrity sha512-XzxSKRk2UJUVeGiOt1jU2hyo412Qee1Q0Xsfw4A2r8EoUIo48XKSWfusYe7E53fSPr0aYgZxPevnFdcUXimpdA==
dependencies: dependencies:
"@turf/boolean-contains" "^6.5.0" "@turf/boolean-contains" "^6.5.0"
"@turf/kinks" "^6.5.0" "@turf/kinks" "^6.5.0"

Loading…
Cancel
Save