diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 6280fb94c2..e927abe7b2 100644 --- a/ui-ngx/package.json +++ b/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", diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index 2d3fc6b2fe..078eadc898 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/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 { private circle: L.Circle; - private circleStyle: L.PathOptions; + private circleStyleInfo: ShapeStyleInfo; private editing = false; constructor(data: FormattedData, @@ -54,16 +54,29 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem, dsData: FormattedData[]): 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, dsData: FormattedData[]): 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, _dsData: FormattedData[]): void { @@ -126,7 +141,7 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem 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 []; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index 3790ce2186..cfe564e7fd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/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, @@ -54,16 +54,29 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem, dsData: FormattedData[]): 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, dsData: FormattedData[]): 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, _dsData: FormattedData[]): void { @@ -230,7 +245,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem { @@ -238,7 +253,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem { + + 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; + + protected abstract computePattern(data: FormattedData, + dsData: FormattedData[]): Observable; + + public processPattern(data: FormattedData, + dsData: FormattedData[], prevPatternId?: string): Observable { + 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 { + + private fillColorProcessor: DataLayerColorProcessor; + + constructor(protected dataLayer: TbMapDataLayer, + protected settings: DataLayerColorSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + this.fillColorProcessor = new DataLayerColorProcessor(this.dataLayer, this.settings); + return this.fillColorProcessor.setup(); + } + + protected computePattern(data: FormattedData, dsData: FormattedData[]): Observable { + const fillColor = this.fillColorProcessor.processColor(data, dsData); + const shapePatternInfo: ShapePatternInfo = { + type: ShapeFillType.color, + fillColor + }; + return of(shapePatternInfo); + } + +} + +class ShapeImagePatternProcessor extends ShapePatternProcessor { + + private shapeFillImageFunction: CompiledTbFunction; + + constructor(protected dataLayer: TbMapDataLayer, + protected settings: ShapeFillImageSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + if (this.settings.type === ShapeFillImageType.function) { + return parseTbFunction(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, dsData: FormattedData[]): Observable { + 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 { + 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 { + + private colorProcessor: DataLayerColorProcessor; + private spaceColorProcessor: DataLayerColorProcessor; + + constructor(protected dataLayer: TbMapDataLayer, + protected settings: ShapeFillStripeSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + 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, dsData: FormattedData[]): Observable { + 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> extends TbLatestMapDataLayer { - public fillColorProcessor: DataLayerColorProcessor; - public strokeColorProcessor: DataLayerColorProcessor; + private shapePatternProcessor: ShapePatternProcessor; + private strokeColorProcessor: DataLayerColorProcessor; protected constructor(protected map: TbMap, inputSettings: S) { super(map, inputSettings); } - public getShapeStyle(data: FormattedData, dsData: FormattedData[]): 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, dsData: FormattedData[], fillPatternId: string): Observable { + 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 { - 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()]); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 79e2f11ce6..6176975975 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/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 implements L.TB.SidebarControl { private readonly sidebar: JQuery; @@ -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 (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 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 (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 extends PatternElement 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 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 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 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, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 24a02c6875..aa8dd67494 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/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 { protected addPolygonDataLayers: TbLatestMapDataLayer[]; protected addCircleDataLayers: TbLatestMapDataLayer[]; + protected shapePatternStorage: ShapePatternStorage = {}; + private readonly mapResize$: ResizeObserver; private tooltipInstances: TooltipInstancesData[] = []; @@ -333,7 +336,11 @@ export abstract class TbMap { 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 { 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 { 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(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts index 24e89aecd5..132177d0eb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts +++ b/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, private popover: TbPopoverComponent) { - super(store); } ngOnInit(): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index d124757183..0ca912146c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -409,17 +409,42 @@ -
-
widgets.maps.data-layer.fill-color
- +
+
+
{{ 'widgets.maps.data-layer.shape.fill' | translate }}
+ + {{ 'widgets.maps.data-layer.shape.fill-type-color' | translate }} + {{ 'widgets.maps.data-layer.shape.fill-type-stripe' | translate }} + {{ 'widgets.maps.data-layer.shape.fill-type-image' | translate }} + +
+
+
widgets.maps.data-layer.shape.color
+ +
+
+
widgets.maps.data-layer.shape.stripe-pattern
+ + +
+
+
widgets.maps.data-layer.shape.image
+ +
-
widgets.maps.data-layer.stroke
+
widgets.maps.data-layer.shape.stroke
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index e7e11284eb..48854adc44 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/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 = []; datasourceTypesTranslations = datasourceTypeTranslationMap; @@ -266,7 +268,10 @@ export class MapDataLayerDialogComponent extends DialogComponent + this.updateValidators() + ); break; } this.dataLayerFormGroup.get('dsType').valueChanges.pipe( @@ -349,8 +359,9 @@ export class MapDataLayerDialogComponent extends DialogComponent +
+
widgets.maps.data-layer.shape.fill-image
+
+ + + {{ 'widgets.maps.data-layer.shape.fill-image-type-image' | translate }} + + + {{ 'widgets.maps.data-layer.shape.fill-image-type-function' | translate }} + + +
+
+
+ +
+
widgets.maps.data-layer.shape.opacity
+ + + +
+
+
widgets.maps.data-layer.shape.angle
+ + +
deg
+
+
+
+
widgets.maps.data-layer.shape.scale
+ + + +
+
+
+
+
+ + + + +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.scss new file mode 100644 index 0000000000..5c127549db --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts new file mode 100644 index 0000000000..0d14c5f81c --- /dev/null +++ b/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(); + + 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}); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html new file mode 100644 index 0000000000..da4feaccf2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html @@ -0,0 +1,29 @@ + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts new file mode 100644 index 0000000000..5f707abced --- /dev/null +++ b/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(); + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.html new file mode 100644 index 0000000000..c4b931dafd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.html @@ -0,0 +1,84 @@ + +
+
widgets.maps.data-layer.shape.stripe-pattern
+
+
+
+
widgets.maps.data-layer.shape.first-stripe
+
+ + + px + + +
+
+
+
widgets.maps.data-layer.shape.second-stripe
+
+ + + px + + +
+
+
+
widgets.maps.data-layer.shape.angle
+ + +
deg
+
+
+
+
+ widgets.background.preview +
+
+
+
+
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.scss new file mode 100644 index 0000000000..5f3af9894b --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts new file mode 100644 index 0000000000..c9df56338b --- /dev/null +++ b/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(); + + 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`) + }; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.html new file mode 100644 index 0000000000..2188e97ae6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.html @@ -0,0 +1,27 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts new file mode 100644 index 0000000000..63b2b4b212 --- /dev/null +++ b/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 = ` + + + + + + + + `; + const encodedSvg = stringToBase64(svgStr); + return `data:image/svg+xml;base64,${encodedSvg}`; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index c00c816073..233b202684 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/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, diff --git a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts index bac8c8fb75..e09d5e5756 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts @@ -487,8 +487,40 @@ export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial => 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 => 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, dsData: FormattedData[]) => string; @@ -1126,6 +1205,9 @@ export type ClusterMarkerColorFunction = (data: FormattedData[] export type MarkerPositionFunction = (origXPos: number, origYPos: number, data: FormattedData, dsData: FormattedData[], aspect: number) => { x: number, y: number }; +export type ShapeFillImageFunction = (data: FormattedData, images: string[], + dsData: FormattedData[]) => 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 => new Obse image.src = imageUrl; }); -const loadImageAspect = (imageUrl: string): Observable => - 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; }) diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn.md new file mode 100644 index 0000000000..b13d612c8e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn.md @@ -0,0 +1,52 @@ +#### Shape fill image function + +
+
+ +*function (data, images, dsData): {url: string}* + +A JavaScript function used to compute shape fill image. + +**Parameters:** + +
    + {% include widget/lib/map/shape_fill_image_fn_args %} +
+ +**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. + +
+ +##### Examples + +
    +
  • +TODO: +
  • +
+ +```javascript +TODO: +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn_args.md new file mode 100644 index 0000000000..07b60f72ae --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/shape_fill_image_fn_args.md @@ -0,0 +1,9 @@ +
  • data: FormattedData object associated with data layer (markers/polygons/circles) or data point of the route (trips data layer).
    + Represents basic entity properties (ex. entityId, entityName)
    and provides access to other entity attributes/timeseries declared in datasource of the data layer configuration. +
  • +
  • images: string[] - array of image urls configured in the Shape fill images section. +
  • +
  • dsData: FormattedData[] - All available data associated with data layers including additional datasources as array of FormattedData objects
    + resolved from configured datasources. Each object represents basic entity properties (ex. entityId, entityName)
    + and provides access to other entity attributes/timeseries declared in datasources of data layers configuration including additional datasources of the map configuration. +
  • diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index ec047f91ae..e4f4721f3c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/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", diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index 1ea249b478..3b982a527e 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -25,6 +25,18 @@ declare module 'leaflet' { interface MarkerOptions { tbMarkerData?: FormattedData; } + 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(options: O): SidebarPaneControl; diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 602839b2da..6e86f3c2b0 100644 --- a/ui-ngx/yarn.lock +++ b/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"