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",
"@flowjs/flow.js": "^2.14.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",
"@mat-datetimepicker/core": "~14.0.0",
"@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';
import L from 'leaflet';
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 { Observable } from 'rxjs';
import { isNotEmptyStr } from '@core/utils';
@ -36,7 +36,7 @@ import { map } from 'rxjs/operators';
class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> {
private circle: L.Circle;
private circleStyle: L.PathOptions;
private circleStyleInfo: ShapeStyleInfo;
private editing = false;
constructor(data: FormattedData<TbMapDatasource>,
@ -54,16 +54,29 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettin
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 {
const circleData = this.dataLayer.extractCircleCoordinates(data);
const center = new L.LatLng(circleData.latitude, circleData.longitude);
this.circleStyle = this.dataLayer.getShapeStyle(data, dsData);
this.circle = L.circle(center, {
bubblingMouseEvents: !this.dataLayer.isEditMode(),
radius: circleData.radius,
...this.circleStyle,
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);
return this.circle;
}
@ -78,11 +91,13 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettin
}
protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.circleStyle = this.dataLayer.getShapeStyle(data, dsData);
this.updateCircleShape(data);
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
this.circle.setStyle(this.circleStyle);
this.dataLayer.getShapeStyle(data, dsData, this.circleStyleInfo?.patternId).subscribe((styleInfo) => {
this.circleStyleInfo = styleInfo;
this.updateCircleShape(data);
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
this.circle.setStyle(this.circleStyleInfo.style);
});
}
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:markerdragend', () => this.editing = false);
this.circle.on('pm:edit', () => this.saveCircleCoordinates());
this.circle.pm.enable();
this.circle.pm.enable({draggable: true, snappable: this.dataLayer.isSnappable()});
}
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';
import L from 'leaflet';
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 { Observable } from 'rxjs';
import { isNotEmptyStr, isString } from '@core/utils';
@ -36,7 +36,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
private polygonContainer: L.FeatureGroup;
private polygon: L.Polygon;
private polygonStyle: L.PathOptions;
private polygonStyleInfo: ShapeStyleInfo;
private editing = false;
constructor(data: FormattedData<TbMapDatasource>,
@ -54,16 +54,29 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
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 {
const polyData = this.dataLayer.extractPolygonCoordinates(data);
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.polygonStyle,
noClip: true,
snapIgnore: !this.dataLayer.isSnappable(),
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.polygon.addTo(this.polygonContainer);
@ -81,13 +94,15 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
}
protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData);
this.updatePolygonShape(data);
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) {
this.polygon.setStyle(this.polygonStyle);
}
this.dataLayer.getShapeStyle(data, dsData, this.polygonStyleInfo?.patternId).subscribe((styleInfo) => {
this.polygonStyleInfo = styleInfo;
this.updatePolygonShape(data);
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) {
this.polygon.setStyle(this.polygonStyleInfo.style);
}
});
}
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void {
@ -230,7 +245,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
this.polygonContainer.closePopup();
this.editing = 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});
this.addItemClass('tb-cut-mode');
this.polygon.once('pm:cut', (e) => {
@ -238,7 +253,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
if (this.polygon instanceof L.Rectangle) {
this.polygonContainer.removeLayer(this.polygon);
this.polygon = L.polygon(e.layer.getLatLngs(), {
...this.polygonStyle,
...this.polygonStyleInfo.style,
snapIgnore: !this.dataLayer.isSnappable(),
bubblingMouseEvents: !this.dataLayer.isEditMode()
});
@ -284,7 +299,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
private disablePolygonCutMode(cutButton?: L.TB.ToolbarButton) {
this.editing = false;
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.polygon.off('pm:cut');
const map = this.dataLayer.getMap().getMap();
@ -338,9 +353,10 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
if (this.polygon instanceof L.Rectangle) {
this.polygonContainer.removeLayer(this.polygon);
this.polygon = L.polygon(polyData, {
...this.polygonStyle,
...this.polygonStyleInfo.style,
snapIgnore: !this.dataLayer.isSnappable(),
bubblingMouseEvents: !this.dataLayer.isEditMode()
bubblingMouseEvents: !this.dataLayer.isEditMode(),
noClip: true
});
this.polygon.addTo(this.polygonContainer);
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.
///
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 { 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 { 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> {
public fillColorProcessor: DataLayerColorProcessor;
public strokeColorProcessor: DataLayerColorProcessor;
private shapePatternProcessor: ShapePatternProcessor;
private strokeColorProcessor: DataLayerColorProcessor;
protected constructor(protected map: TbMap<any>,
inputSettings: S) {
super(map, inputSettings);
}
public getShapeStyle(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.PathOptions {
const fill = this.fillColorProcessor.processColor(data, dsData);
const stroke = this.strokeColorProcessor.processColor(data, dsData);
return {
fill: true,
fillColor: fill,
color: stroke,
weight: this.settings.strokeWeight,
fillOpacity: 1,
opacity: 1
};
public getShapeStyle(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[], fillPatternId: string): Observable<ShapeStyleInfo> {
return this.shapePatternProcessor.processPattern(data, dsData, fillPatternId).pipe(
map((patternWithId) => {
const stroke = this.strokeColorProcessor.processColor(data, dsData);
const style: L.PathOptions = {
fill: true,
fillPattern: patternWithId.pattern,
color: stroke,
weight: this.settings.strokeWeight,
fillOpacity: 1,
opacity: 1
};
return {
patternId: patternWithId.patternId,
style
}
})
);
}
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> {
this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor);
this.shapePatternProcessor = ShapePatternProcessor.fromSettings(this, this.settings);
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 { guid, isNotEmptyStr } from '@core/utils';
import { guid, isDefinedAndNotNull, isNotEmptyStr } from '@core/utils';
import 'leaflet-providers';
import { Map as MapLibreGLMap, LngLat as MapLibreGLLngLat } from 'maplibre-gl';
import '@geoman-io/leaflet-geoman-free';
@ -27,6 +27,64 @@ import { of } from 'rxjs';
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 {
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 => {
return new SidebarControl(options);
}
@ -952,6 +1377,12 @@ L.TB = L.TB || {
ToolbarButton,
ToolbarControl,
BottomToolbarControl,
Pattern,
PatternElement,
PatternShape,
PatternRect,
PatternPath,
PatternImage,
sidebar,
sidebarPane,
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 ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide;
import { ShapePatternStorage } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer';
type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]};
@ -132,6 +133,8 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected addPolygonDataLayers: TbLatestMapDataLayer<any>[];
protected addCircleDataLayers: TbLatestMapDataLayer<any>[];
protected shapePatternStorage: ShapePatternStorage = {};
private readonly mapResize$: ResizeObserver;
private tooltipInstances: TooltipInstancesData[] = [];
@ -333,7 +336,11 @@ export abstract class TbMap<S extends BaseMapSettings> {
type: widgetType.latest,
callbacks: {
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,
callbacks: {
onDataUpdated: (subscription) => {
this.updateTrips(subscription);
try {
this.updateTrips(subscription);
} catch (e) {
console.error(e);
}
},
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;
}
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() {
this.updateEditButtonsStates();
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 { PageComponent } from '@shared/components/page.component';
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 { 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'],
encapsulation: ViewEncapsulation.None
})
export class SelectMapEntityPanelComponent extends PageComponent implements OnInit {
export class SelectMapEntityPanelComponent implements OnInit {
@Input()
entities: UnplacedMapDataItem[];
@ -42,9 +39,7 @@ export class SelectMapEntityPanelComponent extends PageComponent implements OnIn
selectedEntity: UnplacedMapDataItem = null;
constructor(private fb: UntypedFormBuilder,
protected store: Store<AppState>,
private popover: TbPopoverComponent) {
super(store);
}
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>
</ng-container>
<ng-container *ngIf="['polygons', 'circles'].includes(dataLayerType)">
<div class="tb-form-row space-between">
<div translate>widgets.maps.data-layer.fill-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 class="tb-form-panel stroked">
<div class="flex flex-1 flex-row items-center justify-between xs:flex-col xs:items-start xs:gap-3">
<div class="tb-form-panel-title">{{ 'widgets.maps.data-layer.shape.fill' | translate }}</div>
<tb-toggle-select formControlName="fillType" (click)="$event.stopPropagation()">
<tb-toggle-option [value]="ShapeFillType.color">{{ 'widgets.maps.data-layer.shape.fill-type-color' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="ShapeFillType.stripe">{{ 'widgets.maps.data-layer.shape.fill-type-stripe' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="ShapeFillType.image">{{ 'widgets.maps.data-layer.shape.fill-type-image' | translate }}</tb-toggle-option>
</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 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">
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<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,
pathDecoratorSymbolTranslationMap,
PolygonsDataLayerSettings,
ShapeDataLayerSettings,
ShapeDataLayerSettings, ShapeFillType,
TripsDataLayerSettings,
updateDataKeyToNewDsType
} from '@shared/models/widget/maps/map.models';
@ -78,6 +78,8 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
MarkerType = MarkerType;
ShapeFillType = ShapeFillType;
datasourceTypes: Array<DatasourceType> = [];
datasourceTypesTranslations = datasourceTypeTranslationMap;
@ -266,7 +268,10 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
case 'circles':
this.dataLayerEditActions = dataLayerEditActions;
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('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('strokeWeight', this.fb.control(shapeDataLayer.strokeWeight, [Validators.required, Validators.min(0)]));
if (this.dataLayerType === 'polygons') {
@ -280,6 +285,11 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
const circlesDataLayer = this.settings as CirclesDataLayerSettings;
this.dataLayerFormGroup.addControl('circleKey', this.fb.control(circlesDataLayer.circleKey, Validators.required));
}
this.dataLayerFormGroup.get('fillType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() =>
this.updateValidators()
);
break;
}
this.dataLayerFormGroup.get('dsType').valueChanges.pipe(
@ -349,8 +359,9 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
}
if (this.dataLayerType === 'markers') {
this.updateMarkerTypeValidators();
}
if (this.dataLayerType === 'trips') {
} else if (['polygons', 'circles'].includes(this.dataLayerType)) {
this.updateFillTypeValidators();
} else if (this.dataLayerType === 'trips') {
const showMarker: boolean = this.dataLayerFormGroup.get('showMarker').value;
if (showMarker) {
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') {
const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value;
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 {
MapDataSourceRowComponent
} 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({
declarations: [
@ -342,6 +354,10 @@ import {
MarkerImageSettingsComponent,
MarkerImageSettingsPanelComponent,
MarkerClusteringSettingsComponent,
ShapeFillStripeSettingsComponent,
ShapeFillStripeSettingsPanelComponent,
ShapeFillImageSettingsComponent,
ShapeFillImageSettingsPanelComponent,
MapDataLayerDialogComponent,
MapDataLayerRowComponent,
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);
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 {
fillColor: DataLayerColorSettings;
fillType: ShapeFillType;
fillColor?: DataLayerColorSettings;
fillImage?: ShapeFillImageSettings;
fillStripe?: ShapeFillStripeSettings;
strokeColor: DataLayerColorSettings;
strokeWeight: number;
}
@ -510,10 +542,31 @@ export const defaultPolygonsDataLayerSettings = (mapType: MapType, functionsOnly
} as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings(mapType) as PolygonsDataLayerSettings);
export const defaultBasePolygonsDataLayerSettings = (mapType: MapType): Partial<PolygonsDataLayerSettings> => mergeDeep({
fillType: ShapeFillType.color,
fillColor: {
type: DataLayerColorType.constant,
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: {
type: DataLayerColorType.constant,
color: '#3388ff',
@ -539,10 +592,31 @@ export const defaultCirclesDataLayerSettings = (mapType: MapType, functionsOnly
} as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings(mapType) as CirclesDataLayerSettings);
export const defaultBaseCirclesDataLayerSettings = (mapType: MapType): Partial<CirclesDataLayerSettings> => mergeDeep({
fillType: ShapeFillType.color,
fillColor: {
type: DataLayerColorType.constant,
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: {
type: DataLayerColorType.constant,
color: '#3388ff',
@ -596,10 +670,8 @@ export const mapDataSourceValid = (dataSource: MapDataSourceSettings): boolean =
if (dataSource.dsType === DatasourceType.device && !dataSource.dsDeviceId) {
return false;
}
if (dataSource.dsType === DatasourceType.entity && !dataSource.dsEntityAliasId) {
return false;
}
return true;
return !(dataSource.dsType === DatasourceType.entity && !dataSource.dsEntityAliasId);
};
export const mapDataSourceValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
@ -1112,6 +1184,13 @@ export interface MarkerIconInfo {
size: [number, number];
}
export interface ShapeFillImageInfo {
url: string;
opacity?: number;
angle?: number;
scale?: number;
}
export type MapStringFunction = (data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[]) => string;
@ -1126,6 +1205,9 @@ export type ClusterMarkerColorFunction = (data: FormattedData<TbMapDatasource>[]
export type MarkerPositionFunction = (origXPos: number, origYPos: number, data: FormattedData<TbMapDatasource>,
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 TbPolygonRawCoordinates = TbPolygonRawCoordinate[];
export type TbPolyData = L.LatLngTuple[] | L.LatLngTuple[][] | L.LatLngTuple[][][];
@ -1271,11 +1353,13 @@ const imageLoader = (imageUrl: string): Observable<HTMLImageElement> => new Obse
image.src = imageUrl;
});
const loadImageAspect = (imageUrl: string): Observable<number> =>
imageLoader(imageUrl).pipe(map(image => image.width / image.height));
const loadImageSize = (imageUrl: string): Observable<[number, number]> =>
imageLoader(imageUrl).pipe(map(image => [image.width, image.height]));
export interface ImageWithAspect {
url: string;
width: number;
height: number;
aspect: number;
}
@ -1289,9 +1373,14 @@ export const loadImageWithAspect = (imagePipe: ImagePipe, imageUrl: string): Obs
return imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).pipe(
switchMap((res) => {
const url = res as string;
return loadImageAspect(url).pipe(
map((aspect) => {
imageWithAspect = {url, aspect};
return loadImageSize(url).pipe(
map((size) => {
imageWithAspect = {
url,
width: size[0],
height: size[1],
aspect: size[0]/size[1]
};
imageAspectMap[hash] = 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-hint": "List of group names assigned to the overlay, used to toggle its visibility on the map.",
"color": "Color",
"fill-color": "Fill color",
"stroke": "Stroke",
"color-settings": "Color settings",
"color-type-constant": "Constant",
"color-type-range": "Range",
@ -8127,6 +8125,27 @@
"points": "Points",
"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-key": "Polygon key",
"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 {
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 {
_url: string;
@ -152,6 +164,109 @@ declare module 'leaflet' {
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 sidebarPane<O extends SidebarPaneControlOptions>(options: O): SidebarPaneControl<O>;

8
ui-ngx/yarn.lock

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

Loading…
Cancel
Save